This commit is contained in:
Jakob Kordež 2023-09-13 16:25:30 +02:00
parent 08a27fb4fc
commit eb29ab1b31
39 changed files with 1081 additions and 191 deletions

View File

@ -1,17 +1,31 @@
import { IsDate, IsOptional, IsString, MinLength } from 'class-validator';
import {
IsBoolean,
IsDateString,
IsOptional,
IsString,
IsUppercase,
Matches,
} from 'class-validator';
export class CreateEventDto {
@IsString()
@MinLength(3)
@IsUppercase()
@Matches(/^[A-Z\d]+\d+[A-Z]+$/, { message: 'Invalid callsign' })
callsign: string;
@IsString()
@IsOptional()
description: string;
@IsDate()
@IsDateString()
@IsOptional()
fromDateTime: Date;
@IsDate()
@IsDateString()
@IsOptional()
toDateTime: Date;
@IsBoolean()
@IsOptional()
isPrivate: boolean;
}

View File

@ -6,6 +6,7 @@ import {
Patch,
Param,
Delete,
BadRequestException,
} from '@nestjs/common';
import { EventsService } from './events.service';
import { CreateEventDto } from './dto/create-event.dto';
@ -14,6 +15,9 @@ import { MongoIdPipe } from 'src/pipes/mongo-id.pipe';
import { Event } from './schemas/event.schema';
import { Roles } from 'src/decorators/roles.decorator';
import { Role } from 'src/enums/role.enum';
import { Public } from 'src/decorators/public.decorator';
import { RequestUser } from 'src/decorators/request-user.decorator';
import { UserTokenData } from 'src/auth/interfaces/user-token-data.interface';
@Controller('events')
export class EventsController {
@ -22,6 +26,11 @@ export class EventsController {
@Roles(Role.Admin)
@Post()
create(@Body() createEventDto: CreateEventDto): Promise<Event> {
const from = createEventDto.fromDateTime;
const to = createEventDto.toDateTime;
if (from && to && from > to)
throw new BadRequestException('fromDateTime must be before toDateTime');
return this.eventsService.create(createEventDto);
}
@ -31,14 +40,21 @@ export class EventsController {
return this.eventsService.findAll();
}
@Get('private')
findPrivate(@RequestUser() user: UserTokenData): Promise<Event[]> {
return this.eventsService.findPrivate(user.id);
}
@Public()
@Get()
findCurrent(): Promise<Event[]> {
return this.eventsService.findCurrent();
}
@Public()
@Get(':id')
findOne(@Param('id', MongoIdPipe) id: string): Promise<Event> {
return this.eventsService.findOne(id);
return this.eventsService.findOne(id, false);
}
@Roles(Role.Admin)
@ -50,6 +66,24 @@ export class EventsController {
return this.eventsService.update(id, updateEventDto);
}
@Roles(Role.Admin)
@Get(':id/grant/:userId')
grantAccess(
@Param('id', MongoIdPipe) id: string,
@Param('userId', MongoIdPipe) userId: string,
): Promise<Event> {
return this.eventsService.grantAccess(id, userId);
}
@Roles(Role.Admin)
@Get(':id/revoke/:userId')
revokeAccess(
@Param('id', MongoIdPipe) id: string,
@Param('userId', MongoIdPipe) userId: string,
): Promise<Event> {
return this.eventsService.revokeAccess(id, userId);
}
@Roles(Role.Admin)
@Delete(':id')
remove(@Param('id', MongoIdPipe) id: string): Promise<Event> {

View File

@ -24,13 +24,42 @@ export class EventsService {
const now = new Date();
return this.eventModel
.find({
$and: [{ fromDateTime: { $lte: now } }, { toDateTime: { $gte: now } }],
$and: [
{
$or: [{ fromDateTime: [null] }, { fromDateTime: { $lte: now } }],
},
{
$or: [{ toDateTime: [null] }, { toDateTime: { $gte: now } }],
},
],
$nor: [{ isDeleted: true }, { isPrivate: true }],
})
.exec();
}
findOne(id: string): Promise<Event> {
return this.eventModel.findById(id).exec();
findPrivate(userId: string): Promise<Event[]> {
const now = new Date();
return this.eventModel
.find({
$and: [
{
$or: [{ fromDateTime: [null] }, { fromDateTime: { $lte: now } }],
},
{
$or: [{ toDateTime: [null] }, { toDateTime: { $gte: now } }],
},
],
$nor: [{ isDeleted: true }],
isPrivate: true,
access: userId,
})
.exec();
}
findOne(id: string, populate: boolean): Promise<Event> {
let q = this.eventModel.findById(id);
if (populate) q = q.populate('access');
return q.exec();
}
update(id: string, updateEventDto: UpdateEventDto): Promise<Event> {
@ -39,6 +68,18 @@ export class EventsService {
.exec();
}
grantAccess(id: string, userId: string): Promise<Event> {
return this.eventModel
.findByIdAndUpdate(id, { $addToSet: { access: userId } }, { new: true })
.exec();
}
revokeAccess(id: string, userId: string): Promise<Event> {
return this.eventModel
.findByIdAndUpdate(id, { $pull: { access: userId } }, { new: true })
.exec();
}
setDeleted(id: string): Promise<Event> {
return this.eventModel
.findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true })

View File

@ -19,6 +19,11 @@ export class Event {
@Prop()
toDateTime: Date;
@Prop({
default: false,
})
isPrivate: boolean;
@Prop({
type: [{ type: String, ref: User.name }],
default: [],

View File

@ -3,12 +3,15 @@ import {
IsOptional,
IsPhoneNumber,
IsString,
IsUppercase,
Matches,
MinLength,
} from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(3)
@IsUppercase()
@Matches(/^[A-Z\d]+\d+[A-Z]+$/, { message: 'Invalid callsign' })
username: string;
@IsString()

View File

@ -7,6 +7,8 @@ import {
Param,
Delete,
NotFoundException,
BadRequestException,
ParseArrayPipe,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
@ -24,7 +26,12 @@ export class UsersController {
@Roles(Role.Admin)
@Post()
create(@Body() createUserDto: CreateUserDto): Promise<User> {
async create(@Body() createUserDto: CreateUserDto): Promise<User> {
const exitsting = await this.usersService.findByUsername(
createUserDto.username,
);
if (exitsting) throw new BadRequestException('Username taken');
return this.usersService.create(createUserDto);
}
@ -41,6 +48,7 @@ export class UsersController {
return user;
}
@Roles(Role.Admin)
@Get('search/:username')
findByUsername(@Param('username') username: string): Promise<User> {
const user = this.usersService.findByUsername(username);
@ -48,11 +56,18 @@ export class UsersController {
return user;
}
@Roles(Role.Admin)
@Get(':id')
findOne(@Param('id', MongoIdPipe) id: string): Promise<User> {
return this.usersService.findOne(id);
}
@Roles(Role.Admin)
@Post('many')
findMany(@Body(ParseArrayPipe) ids: string[]): Promise<User[]> {
return this.usersService.findMany(ids);
}
@Roles(Role.Admin)
@Patch(':id')
update(

View File

@ -17,15 +17,21 @@ export class UsersService {
}
findAll(): Promise<User[]> {
return this.userModel.find({ isDeleted: { $in: [false, null] } }).exec();
return this.userModel.find({ $nor: [{ isDeleted: true }] }).exec();
}
findOne(id: string): Promise<User> {
return this.userModel.findById(id).exec();
}
findMany(ids: string[]): Promise<User[]> {
return this.userModel.find({ _id: ids }).exec();
}
findByUsername(username: string): Promise<User> {
return this.userModel.findOne({ username }).exec();
return this.userModel
.findOne({ username, $nor: [{ isDeleted: true }] })
.exec();
}
update(id: string, updateUserDto: UpdateUserDto): Promise<User> {

View File

@ -1,5 +1,10 @@
{
"extends": ["next/core-web-vitals", "prettier"],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
"next/core-web-vitals",
"prettier"
],
"rules": {
"no-unused-vars": "warn"
}

View File

@ -1,4 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
const nextConfig = {
rewrites: async () => [{
source: '/api/:path*',
destination: 'http://localhost:3001/:path*' // Proxy to Backend
}]
}
module.exports = nextConfig

View File

@ -1,8 +1,10 @@
import axios from 'axios';
import { User } from './interfaces/user.interface';
import { CreateUserDto } from './interfaces/create-user-dto.interface';
import { Event } from './interfaces/event.interface';
import { CreateEventDto } from './interfaces/create-event-dto.interface';
const baseURL = 'http://localhost:3001/';
const baseURL = '/api';
const api = axios.create({
baseURL,
@ -11,6 +13,11 @@ const api = axios.create({
},
});
api.interceptors.response.use((originalResponse) => {
handleDates(originalResponse.data);
return originalResponse;
});
interface LoginResponse {
accessToken: string;
refreshToken: string;
@ -58,6 +65,11 @@ export const apiFunctions = {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getManyUsers: async (accessToken: string, ids: string[]) => {
return await api.post<User[]>(`/users/many`, ids, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
updateUser: async (accessToken: string, id: string, user: User) => {
return await api.patch<User>(`/users/${id}`, user, {
headers: { Authorization: `Bearer ${accessToken}` },
@ -70,7 +82,7 @@ export const apiFunctions = {
},
// Events
createEvent: async (accessToken: string, event: Event) => {
createEvent: async (accessToken: string, event: CreateEventDto) => {
return await api.post<Event>('/events', event, {
headers: { Authorization: `Bearer ${accessToken}` },
});
@ -80,24 +92,56 @@ export const apiFunctions = {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getCurrentEvents: async (accessToken: string) => {
return await api.get<Event[]>('/events', {
getPrivateEvents: async (accessToken: string) => {
return await api.get<Event[]>('/events/private', {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
getEvent: async (accessToken: string, id: string) => {
return await api.get<Event>(`/events/${id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
getCurrentEvents: async () => {
return await api.get<Event[]>('/events');
},
getEvent: async (id: string) => {
return await api.get<Event>(`/events/${id}`);
},
updateEvent: async (accessToken: string, id: string, event: Event) => {
return await api.patch<Event>(`/events/${id}`, event, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
grantEventAccess: async (accessToken: string, id: string, userId: string) => {
return await api.get<Event>(`/events/${id}/grant/${userId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
revokeEventAccess: async (
accessToken: string,
id: string,
userId: string,
) => {
return await api.get<Event>(`/events/${id}/revoke/${userId}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
deleteEvent: async (accessToken: string, id: string) => {
return await api.delete<Event>(`/events/${id}`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
},
};
const isoDateFormat = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?Z?$/;
function isIsoDateString(value: any): boolean {
return value && typeof value === 'string' && isoDateFormat.test(value);
}
function handleDates(body: any) {
if (body === null || body === undefined || typeof body !== 'object')
return body;
for (const key of Object.keys(body)) {
const value = body[key];
if (isIsoDateString(value)) body[key] = new Date(Date.parse(value));
else if (typeof value === 'object') handleDates(value);
}
}

View File

@ -0,0 +1,141 @@
'use client';
import { apiFunctions } from '@/api';
import { PrivateTag } from '@/components/private-tag';
import { ProgressBar } from '@/components/progress-bar';
import { Event } from '@/interfaces/event.interface';
import { User } from '@/interfaces/user.interface';
import { useAuthState } from '@/state/auth-state';
import { faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface EventComponentProps {
event: Event;
}
export function EventComponent({ event }: EventComponentProps) {
return (
<div className="flex flex-col gap-8">
<div>
<div className="flex flex-col items-start gap-2">
<div>
<h1 className="font-callsign text-4xl font-medium">
{event.callsign}
</h1>
<div className="text-sm opacity-70">{event._id}</div>
</div>
<p className="opacity-90">{event.description}</p>
{event.isPrivate && <PrivateTag />}
</div>
{event.fromDateTime && event.toDateTime && (
<div className="mt-4">
<div className="mb-2 flex justify-between">
<div>{event.fromDateTime.toLocaleString()}</div>
<div>{event.toDateTime.toLocaleString()}</div>
</div>
<ProgressBar start={event.fromDateTime} end={event.toDateTime} />
</div>
)}
</div>
<div className="flex gap-4">
<Link href={`/event/${event._id}`} className="button">
Na stran dogodka
</Link>
</div>
<div className="grid gap-8 sm:grid-cols-2">
<AccessComponent event={event} />
<div>
<h2 className="mb-4 text-2xl">Rezervacije</h2>
</div>
</div>
</div>
);
}
function AccessComponent({ event }: EventComponentProps) {
const [usernameInput, setUsernameInput] = useState('');
const [users, setUsers] = useState<User[]>();
const getAccessToken = useAuthState((state) => state.getAccessToken);
useEffect(() => {
getAccessToken().then((token) => {
if (!token) return;
apiFunctions.getManyUsers(token, event.access).then((res) => {
setUsers(res.data);
});
});
}, []);
async function grantAccess() {
const token = await getAccessToken();
if (!token) return;
const user = await apiFunctions.findByUsername(token, usernameInput);
if (!user.data) return;
try {
await apiFunctions.grantEventAccess(token, event._id, user.data._id);
window.location.reload();
setUsernameInput('');
} catch (err) {
console.error(err);
}
}
async function revokeAccess(userId: string) {
const token = await getAccessToken();
if (!token) return;
try {
await apiFunctions.revokeEventAccess(token, event._id, userId);
window.location.reload();
} catch (err) {
console.error(err);
}
}
return (
<div>
<h2 className="text-2xl">Dostop</h2>
<div className="my-4 flex items-center gap-2">
<input
className="text-input font-callsign flex-1 placeholder:font-normal placeholder:normal-case"
value={usernameInput}
placeholder="Dodaj uporabnika"
onChange={(e) => setUsernameInput(e.target.value)}
/>
<Button onClick={grantAccess}>
<FontAwesomeIcon icon={faPlus} />
</Button>
</div>
{users?.map((user, i) => (
<div
key={i}
className="flex items-center justify-between border-b py-2 last:border-0"
>
<span className="font-callsign px-3 text-lg">{user.username}</span>
<Button onClick={() => revokeAccess(user._id)}>
<FontAwesomeIcon icon={faMinus} />
</Button>
</div>
)) ?? <div>Loading...</div>}
</div>
);
}
function Button({ children, onClick }: any) {
return (
<button
className="h-10 w-10 rounded-full bg-gray-800 hover:bg-gray-700"
onClick={onClick}
>
{children}
</button>
);
}

View File

@ -0,0 +1,40 @@
'use client';
import { apiFunctions } from '@/api';
import { Event } from '@/interfaces/event.interface';
import { useEffect, useState } from 'react';
import { EventComponent } from './event-component';
interface EventPageProps {
params: {
id: string;
};
}
export default function AdminEventPage({ params: { id } }: EventPageProps) {
const [event, setEvent] = useState<Event | null>();
useEffect(() => {
apiFunctions
.getEvent(id)
.then((res) => {
setEvent(res.data);
})
.catch((e) => {
console.log(e);
setEvent(null);
});
}, []);
return (
<>
{event === undefined && <div>Loading...</div>}
{event === null && (
<h1 className="text-center text-2xl font-medium">
404 - Dogodek ne obstaja
</h1>
)}
{event && <EventComponent event={event} />}
</>
);
}

View File

@ -0,0 +1,132 @@
'use client';
import { apiFunctions } from '@/api';
import { useAuthState } from '@/state/auth-state';
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export function CreateEventForm() {
const [callsign, setCallsign] = useState('');
const [description, setDescription] = useState('');
const [fromDateTime, setFromDateTime] = useState<Date>();
const [toDateTime, setToDateTime] = useState<Date>();
const [isPrivate, setIsPrivate] = useState(false);
const [error, setError] = useState<string>();
const getAccessToken = useAuthState((s) => s.getAccessToken);
const router = useRouter();
function submit() {
setError(undefined);
getAccessToken().then((accessToken) => {
if (!accessToken) {
setError('Session expired');
throw new Error('No access token');
}
apiFunctions
.createEvent(accessToken, {
callsign: callsign.toUpperCase(),
description,
fromDateTime: fromDateTime?.toISOString(),
toDateTime: toDateTime?.toISOString(),
isPrivate,
})
.then((res) => {
console.log(res);
router.push('/admin/events');
})
.catch((err) => {
console.error(err);
const msg = err.response.data.message;
if (msg instanceof Array) setError(msg.join(', '));
else setError(msg);
});
});
}
return (
<div className="flex flex-col gap-4 rounded border border-gray-500 p-6">
<div className="flex flex-col gap-1">
<label htmlFor="callsign">Klicni znak</label>
<input
type="text"
id="callsign"
className="text-input font-callsign"
placeholder="S50HQ"
value={callsign}
onChange={(e) => setCallsign(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="description">Opis</label>
<textarea
id="description"
className="text-input"
placeholder="Opis"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="flex flex-row gap-4">
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="fromDateTime">Od</label>
<input
type="datetime-local"
id="fromDateTime"
className={`text-input ${
fromDateTime ? 'border-green-600 outline-green-600' : ''
}`}
onChange={(e) => {
if (!e.target.value) return setFromDateTime(undefined);
const date = new Date(e.target.value + 'Z');
setFromDateTime(date);
}}
/>
</div>
<div className="flex flex-1 flex-col gap-1">
<label htmlFor="toDateTime">Do</label>
<input
type="datetime-local"
id="toDateTime"
className={`text-input ${
toDateTime
? 'border-green-600 focus-visible:outline-green-600'
: ''
}`}
onChange={(e) => {
if (!e.target.value) return setToDateTime(undefined);
const date = new Date(e.target.value + 'Z');
setToDateTime(date);
}}
/>
</div>
</div>
<div className="flex flex-row items-center gap-4 text-lg">
<input
type="checkbox"
id="isPrivate"
className="checkbox"
checked={isPrivate}
onChange={(e) => setIsPrivate(e.target.checked)}
/>
<label htmlFor="isPrivate">Zasebno</label>
</div>
{error && (
<div className="flex flex-row items-center gap-4 rounded border border-red-500 bg-red-500/10 p-4 text-red-600">
<FontAwesomeIcon
icon={faTriangleExclamation}
className="h-6 w-6 text-red-500"
/>
<span>{error[0].toUpperCase() + error.slice(1)}</span>
</div>
)}
<button className="button" onClick={submit}>
Ustvari
</button>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { CreateEventForm } from './create-event-form';
export default function CreateEventPage() {
return (
<div>
<h2 className="mb-4 text-2xl font-medium">Ustvari dogodek</h2>
<CreateEventForm />
</div>
);
}

View File

@ -0,0 +1,45 @@
'use client';
import { apiFunctions } from '@/api';
import { PrivateTag } from '@/components/private-tag';
import { Event } from '@/interfaces/event.interface';
import { useAuthState } from '@/state/auth-state';
import Link from 'next/link';
import { useEffect, useState } from 'react';
export function EventsList() {
const [getAccessToken] = useAuthState((state) => [state.getAccessToken]);
const [events, setEvents] = useState<Event[]>();
useEffect(() => {
getAccessToken().then((token) => {
if (!token) return;
apiFunctions.getAllEvents(token).then((res) => setEvents(res.data));
});
}, []);
return (
<div className="flex flex-col">
{events?.map((event) => (
<Link
key={event._id}
href={`/admin/events/${event._id}`}
className="flex flex-row border-b border-gray-500 px-4 py-3 align-middle transition last:border-none hover:bg-gray-500/10"
>
<div className="flex flex-1 items-center gap-3">
<div className="font-callsign text-2xl font-medium">
{event.callsign}
</div>
{event.isPrivate && <PrivateTag />}
</div>
<div className="flex-1 text-sm opacity-80">
<div>Od: {event.fromDateTime?.toLocaleDateString() ?? '/'}</div>
<div>Do: {event.toDateTime?.toLocaleDateString() ?? '/'}</div>
</div>
</Link>
))}
</div>
);
}

View File

@ -1,3 +1,20 @@
import Link from 'next/link';
import { EventsList } from './events-list';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAdd } from '@fortawesome/free-solid-svg-icons';
export default function AdminEvents() {
return <h2>Events</h2>;
return (
<div>
<div className="mb-4 flex items-center">
<h2 className="flex-1 text-2xl font-medium">Vsi dogodki</h2>
<Link href="/admin/events/create">
<FontAwesomeIcon icon={faAdd} className="text-2xl" />
</Link>
</div>
<EventsList />
</div>
);
}

View File

@ -42,7 +42,7 @@ export default function AdminPageLayout({
key={href}
href={href}
className={`rounded-md border border-gray-300 px-5 py-2 font-medium shadow dark:border-gray-600 ${
href === pathname
pathname.startsWith(href)
? 'bg-black/10 dark:bg-white/20'
: 'bg-black/5 hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/20'
}`}

View File

@ -1,23 +1,34 @@
'use client';
import { apiFunctions } from '@/api';
import { useAuthState } from '@/state/auth-state';
import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
export function CreateUserForm() {
const router = useRouter();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [error, setError] = useState<string>();
const getAccessToken = useAuthState((s) => s.getAccessToken);
function submit() {
setError(undefined);
getAccessToken().then((accessToken) => {
if (!accessToken) throw new Error('No access token');
if (!accessToken) {
setError('Session expired');
throw new Error('No access token');
}
apiFunctions
.createUser(accessToken, {
username,
username: username.toUpperCase(),
password,
name,
email: email || undefined,
@ -25,14 +36,13 @@ export function CreateUserForm() {
})
.then((res) => {
console.log(res);
setUsername('');
setPassword('');
setName('');
setEmail('');
setPhone('');
router.push('/admin/users');
})
.catch((err) => {
console.error(err);
const msg = err.response.data.message;
if (msg instanceof Array) setError(msg.join(', '));
else setError(msg);
});
});
}
@ -44,7 +54,7 @@ export function CreateUserForm() {
<input
type="text"
id="username"
className="text-input"
className="text-input font-callsign"
placeholder="S50HQ"
value={username}
onChange={(e) => setUsername(e.target.value)}
@ -94,6 +104,16 @@ export function CreateUserForm() {
/>
</div>
{error && (
<div className="flex flex-row items-center gap-4 rounded border border-red-500 bg-red-500/10 p-4 text-red-600">
<FontAwesomeIcon
icon={faTriangleExclamation}
className="h-6 w-6 text-red-500"
/>
<span>{error[0].toUpperCase() + error.slice(1)}</span>
</div>
)}
<button className="button" onClick={submit}>
Ustvari
</button>

View File

@ -0,0 +1,11 @@
import { CreateUserForm } from './create-user-form';
export default function CreateUserPage() {
return (
<div>
<h2 className="mb-4 text-2xl font-medium">Ustvari uporabnika</h2>
<CreateUserForm />
</div>
);
}

View File

@ -1,22 +1,22 @@
'use client';
import { apiFunctions } from '@/api';
import { User } from '@/interfaces/user.interface';
import { useAuthState } from '@/state/auth-state';
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Dialog, Transition } from '@headlessui/react';
import { Fragment, useRef } from 'react';
import { Dialog } from '@headlessui/react';
import { useRef } from 'react';
interface DeleteUserDialogProps {
user: User | undefined;
onCancel: () => void;
onConfirm: () => void;
}
export function DeleteUserDialog({
user,
onCancel,
}: {
user: User | undefined;
onCancel: () => void;
}) {
const getAccessToken = useAuthState((s) => s.getAccessToken);
onConfirm,
}: DeleteUserDialogProps) {
const cancelButtonRef = useRef(null);
return (
@ -62,12 +62,7 @@ export function DeleteUserDialog({
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"
onClick={async () => {
const token = await getAccessToken();
if (!token) return;
apiFunctions.deleteUser(token, user!._id);
onCancel();
}}
onClick={onConfirm}
>
Deactivate
</button>

View File

@ -1,22 +1,22 @@
'use client';
import { useAuthState } from '@/state/auth-state';
import NoSSR from 'react-no-ssr';
import { CreateUserForm } from './create-user-form';
import Link from 'next/link';
import { UsersList } from './users-list';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAdd } from '@fortawesome/free-solid-svg-icons';
export default function AdminUsers() {
return (
<>
<h2 className="text-2xl font-medium">Create user</h2>
<div>
<div className="mb-4 flex items-center">
<h2 className="flex-1 text-2xl font-medium">Users</h2>
<CreateUserForm />
<Link href="/admin/users/create">
<FontAwesomeIcon icon={faAdd} className="text-2xl" />
</Link>
</div>
<h2 className="text-2xl font-medium">Users</h2>
<NoSSR>
<UsersList />
</NoSSR>
</>
<UsersList />
</div>
);
}

View File

@ -8,7 +8,6 @@ import { faCrown, faTrash } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useEffect, useState } from 'react';
import { DeleteUserDialog } from './delete-user-dialog';
import { Dialog } from '@headlessui/react';
export function UsersList() {
const [getAccessToken, getMe] = useAuthState((s) => [
@ -20,66 +19,86 @@ export function UsersList() {
const [me, setMe] = useState<User>();
const [deleteUser, setDeleteUser] = useState<User>();
async function getUsers() {
setUsers(undefined);
const accessToken = await getAccessToken();
if (!accessToken) return;
const users = (await apiFunctions.getAllUsers(accessToken)).data;
const me = await getMe();
if (!me) return;
setUsers(users);
setMe(me);
}
useEffect(() => {
const f = async () => {
const accessToken = await getAccessToken();
if (!accessToken) return;
const users = (await apiFunctions.getAllUsers(accessToken)).data;
const me = await getMe();
if (!me) return;
setUsers(users);
setMe(me);
};
f();
getUsers();
}, []);
if (!users || !me) return <div>Loading...</div>;
return (
<div>
{users.map((user: User) => (
<div
key={user._id}
className="flex flex-row border-b border-b-gray-500 px-4 py-2 last:border-b-0"
>
<div className="my-auto flex-1">
<div className="text-xl">
{user.username.toUpperCase()} - {user.name}
</div>{' '}
<div className="text-xs opacity-80">{user._id}</div>
</div>
<div className="my-auto flex-1">
<div className="text-sm opacity-80">Email: {user.email}</div>
<div className="text-sm opacity-80">Phone: {user.phone}</div>
</div>
<button
className={`h-14 w-14 rounded-full hover:bg-yellow-500/30 disabled:hover:bg-transparent ${
user.roles.includes(Role.Admin)
? 'text-yellow-400'
: 'text-gray-400'
}`}
disabled={user._id === me?._id}
<>
<div>
{users.map((user: User) => (
<div
key={user._id}
className="flex flex-row border-b border-b-gray-500 px-4 py-2 last:border-b-0"
>
<FontAwesomeIcon icon={faCrown} className="h-5 w-5 leading-none" />
</button>
<button
className={`h-14 w-14 rounded-full text-red-500 hover:bg-red-500/30 ${
user._id === me?._id ? 'invisible' : ''
}`}
onClick={() => setDeleteUser(user)}
>
<FontAwesomeIcon icon={faTrash} className="h-5 w-5 leading-none" />
</button>
</div>
))}
<div className="my-auto flex-1">
<div className="text-xl">
<span className="font-callsign">
{user.username.toUpperCase()}
</span>{' '}
- {user.name}
</div>{' '}
<div className="text-xs opacity-80">{user._id}</div>
</div>
<div className="my-auto flex-1">
<div className="text-sm opacity-80">Email: {user.email}</div>
<div className="text-sm opacity-80">Phone: {user.phone}</div>
</div>
<button
className={`h-14 w-14 rounded-full hover:bg-yellow-500/30 disabled:hover:bg-transparent ${
user.roles.includes(Role.Admin)
? 'text-yellow-400'
: 'text-gray-400'
}`}
disabled={user._id === me?._id}
>
<FontAwesomeIcon
icon={faCrown}
className="h-5 w-5 leading-none"
/>
</button>
<button
className={`h-14 w-14 rounded-full text-red-500 hover:bg-red-500/30 ${
user._id === me?._id ? 'invisible' : ''
}`}
onClick={() => setDeleteUser(user)}
>
<FontAwesomeIcon
icon={faTrash}
className="h-5 w-5 leading-none"
/>
</button>
</div>
))}
</div>
<DeleteUserDialog
user={deleteUser}
onCancel={() => setDeleteUser(undefined)}
onConfirm={async () => {
const token = await getAccessToken();
if (!token) return;
apiFunctions.deleteUser(token, deleteUser!._id);
setDeleteUser(undefined);
getUsers();
}}
/>
</div>
</>
);
}

View File

@ -0,0 +1,99 @@
'use client';
import { PrivateTag } from '@/components/private-tag';
import { ProgressBar } from '@/components/progress-bar';
import { Event } from '@/interfaces/event.interface';
import { useAuthState } from '@/state/auth-state';
import Link from 'next/link';
import { useEffect, useState } from 'react';
interface EventComponentProps {
event: Event;
}
const bands = ['160m', '80m', '40m', '20m', '15m', '10m', '6m', '2m', '70cm'];
export function EventComponent({ event }: EventComponentProps) {
const [isAuth, setIsAuth] = useState(false);
const isValid = useAuthState((state) => state.isValid);
useEffect(() => {
isValid().then(setIsAuth);
}, []);
return (
<div className="flex flex-col gap-8">
<div>
<div className="flex flex-col items-start gap-2">
<h1 className="font-callsign text-4xl font-medium">
{event.callsign}
</h1>
<p className="opacity-90">{event.description}</p>
{event.isPrivate && <PrivateTag />}
</div>
{event.fromDateTime && event.toDateTime && (
<div className="mt-4">
<div className="mb-2 flex justify-between">
<div>{event.fromDateTime.toLocaleString()}</div>
<div>{event.toDateTime.toLocaleString()}</div>
</div>
<ProgressBar start={event.fromDateTime} end={event.toDateTime} />
</div>
)}
</div>
<div className="flex gap-4">
{isAuth ? (
<Link href={`${event._id}/reserve`} className="button">
Rezerviraj zdaj
</Link>
) : (
<Link href="/login" className="button">
Prijavi se za rezervacijo
</Link>
)}
</div>
<div>
<h2 className="mb-4 text-2xl">Prosti termini</h2>
<table className="block w-full overflow-x-auto whitespace-nowrap pb-2 text-center lg:table">
<thead>
<tr>
<td />
{new Array(11).fill(null).map((_, i) => (
<th key={i} className="px-3">
{new Date(Date.now() + i * 1000 * 60 * 60 * 24)
.toLocaleDateString()
.slice(0, -5)}
</th>
))}
</tr>
</thead>
<tbody>
{bands.map((band) => (
<tr>
<th key={band} className="px-3">
{band}
</th>
{new Array(11).fill(null).map((_, i) => (
<td key={i}>
<div
className={`m-auto h-3 w-full${
i > 0 ? '' : ' rounded-l-full'
}${i == 10 ? ' rounded-r-full' : ''} ${
Math.random() > 0.2 ? 'bg-green-500' : 'bg-red-500'
}`}
/>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import { apiFunctions } from '@/api';
import { Event } from '@/interfaces/event.interface';
import { useEffect, useState } from 'react';
import { EventComponent } from './event-component';
import Link from 'next/link';
interface EventPageProps {
params: {
id: string;
};
}
export default function EventPage({ params: { id } }: EventPageProps) {
const [event, setEvent] = useState<Event | null>();
useEffect(() => {
apiFunctions
.getEvent(id)
.then((res) => {
setEvent(res.data);
})
.catch((e) => {
console.log(e);
setEvent(null);
});
}, []);
return (
<div className="container py-8">
{event === undefined && <div>Loading...</div>}
{event === null && (
<div className="flex flex-col items-center gap-6">
<h1 className="text-2xl font-medium">404 - Dogodek ne obstaja</h1>
<Link href="/" className="button">
Nazaj na domačo stran
</Link>
</div>
)}
{event && <EventComponent event={event} />}
</div>
);
}

View File

@ -1,15 +1,20 @@
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
.text-input {
@apply rounded border border-gray-400 px-4 py-2 dark:bg-[#343434] dark:text-white;
@apply rounded border border-gray-400 px-4 py-2 dark:bg-gray-700 dark:text-white;
}
.button {
@apply bg-primary rounded px-4 py-2 font-bold text-white hover:opacity-90;
@apply rounded bg-primary px-4 py-2 font-bold text-white hover:opacity-90;
}
.header-button {
@apply h-full p-6 leading-none hover:bg-black/10 dark:hover:bg-white/20;
}
.font-callsign {
font-family: var(--callsign-font);
text-transform: uppercase;
}
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

View File

@ -2,9 +2,15 @@
import { Header } from '@/components/header';
import { useThemeState } from '@/state/theme-state';
import { Inter } from 'next/font/google';
import { Allerta, Inter } from 'next/font/google';
import { useEffect, useState } from 'react';
const callsignFont = Allerta({
subsets: ['latin'],
variable: '--callsign-font',
weight: '400',
});
const inter = Inter({ subsets: ['latin'] });
export function LayoutComponent({ children }: { children: React.ReactNode }) {
@ -16,9 +22,9 @@ export function LayoutComponent({ children }: { children: React.ReactNode }) {
}, []);
return (
<html lang="en" className={theme}>
<html lang="sl" className={theme}>
<body
className={`${inter.className} dark:bg-[#121212] dark:text-[#d6d6d6]`}
className={`${inter.className} ${callsignFont.variable} dark:text-light dark:bg-gray-900 dark:[color-scheme:dark]`}
>
<Header />
<main>{children}</main>

View File

@ -1,6 +1,6 @@
import './globals.scss';
import type { Metadata } from 'next';
import { LayoutComponent } from './layout_component';
import { LayoutComponent } from './layout-component';
export const metadata: Metadata = {
title: {

View File

@ -18,42 +18,44 @@ export default function Login() {
});
}, []);
async function login() {
try {
const res = await apiFunctions.login(username.toUpperCase(), password);
useAuthState.setState(res.data);
router.replace('/');
} catch (e) {
console.log(e);
}
}
return (
<div className="mx-auto my-10 flex max-w-2xl flex-col gap-4 rounded-xl bg-gray-100 p-10 dark:bg-[#454545]">
<h1 className="text-2xl font-bold">Prijava</h1>
<div className="flex flex-col gap-1">
<label htmlFor="username">Uporabniško ime</label>
<input
id="username"
type="username"
className="text-input"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
<div className="container py-10">
<div className="mx-auto flex max-w-2xl flex-col gap-4 rounded-xl bg-gray-100 p-10 shadow-lg dark:bg-gray-800">
<h1 className="text-2xl font-bold">Prijava</h1>
<div className="flex flex-col gap-1">
<label htmlFor="username">Uporabniško ime</label>
<input
id="username"
type="username"
className="text-input font-callsign"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password">Geslo</label>
<input
id="password"
type="password"
className="text-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button className="button" onClick={() => login()}>
Prijava
</button>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="password">Geslo</label>
<input
id="password"
type="password"
className="text-input"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<button className="button" onClick={() => login(username, password)}>
Prijava
</button>
</div>
);
}
const login = async (username: string, password: string) => {
try {
const res = (await apiFunctions.login(username, password)).data;
useAuthState.setState(res);
window.location.href = '/';
} catch (e) {
console.log(e);
}
};

View File

@ -0,0 +1,8 @@
export default function NotFound() {
return (
<div className="container flex flex-col items-center gap-2 py-10">
<div className="text-lg font-semibold text-primary">404</div>
<h1 className="text-3xl font-semibold">Stran ne obstaja</h1>
</div>
);
}

View File

@ -1,36 +1,13 @@
import { CurrentEvents } from '@/components/current-events';
import { PrivateEvents } from '@/components/private-events';
import React from 'react';
const znaki = [
{
znak: 'S50YOTA',
od: '1. 12. 2023',
do: '31. 12. 2023',
},
];
export default function Home() {
return (
<div className="container flex flex-col gap-10 py-8">
<div>
<h2 className="mb-4 text-2xl">Trenutni znaki</h2>
<CurrentEvents />
<div className="grid grid-cols-4">
{znaki.map((znak, i) => (
<div
key={i}
className="rounded-lg bg-[#f5f5f5] px-6 py-4 shadow-md dark:bg-white/5"
>
<div className="mb-2 font-mono text-2xl font-medium">
{znak.znak}
</div>
<div className="text-sm">
<div>Od: {znak.od}</div>
<div>Do: {znak.do}</div>
</div>
</div>
))}
</div>
</div>
<PrivateEvents />
<div className="flex flex-col gap-4">
<h2 className="text-2xl">Kako do rezervacije?</h2>
@ -69,7 +46,7 @@ interface TileProps {
function Tile({ title, children, image }: TileProps) {
return (
<div className="rounded-lg bg-[#f5f5f5] p-6 shadow-md dark:bg-white/5">
<div className="rounded-lg bg-gray-100 p-6 shadow-md dark:bg-white/5">
<div className="mb-2 text-xl font-medium">{title}</div>
<div>{children}</div>
</div>

View File

@ -0,0 +1,33 @@
'use client';
import { apiFunctions } from '@/api';
import { Event } from '@/interfaces/event.interface';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { EventCard } from './event-card';
export function CurrentEvents() {
const [events, setEvents] = useState<Event[]>();
useEffect(() => {
apiFunctions.getCurrentEvents().then((res) => setEvents(res.data));
}, []);
return (
<div className="flex flex-col gap-2">
<h2 className="text-2xl">Trenutno</h2>
{(events?.length ?? 0) === 0 ? (
<p className="opacity-90">Yoooo, no events, come back another day</p>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{events?.map((event, i) => (
<Link key={i} href={`/event/${event._id}`}>
<EventCard event={event} />
</Link>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,33 @@
import { Event } from '@/interfaces/event.interface';
import { ProgressBar } from './progress-bar';
interface EventCardProps {
event: Event;
}
export function EventCard({ event }: EventCardProps) {
return (
<div className="flex h-full flex-col justify-between gap-3 rounded-lg bg-gray-100 px-6 py-4 shadow-md dark:bg-white/5">
<div>
<div className="font-callsign text-2xl font-medium">
{event.callsign}
</div>
{event.description && (
<div className="mt-2 text-sm opacity-80">{event.description}</div>
)}
</div>
{event.fromDateTime && event.toDateTime && (
<div>
<div className="mb-1 flex flex-row justify-between text-sm">
<div>{event.fromDateTime.toLocaleDateString()}</div>
<div>{event.toDateTime.toLocaleDateString()}</div>
</div>
<ProgressBar start={event.fromDateTime} end={event.toDateTime} />
</div>
)}
</div>
);
}

View File

@ -19,7 +19,7 @@ export function Header() {
}, []);
return (
<div className="bg-primary flex h-16 select-none flex-row justify-between text-white shadow">
<div className="flex h-16 select-none flex-row justify-between bg-primary text-white shadow">
<Link href="/" className="my-auto ml-4 text-2xl font-semibold">
Ham Reserve
</Link>
@ -60,7 +60,7 @@ function UserHeader() {
}`}
>
<div
className={`w-56 origin-top-right rounded-md bg-[#f8f8f8] py-2 text-black shadow-sm ring-1 ring-inset ring-gray-300 duration-100 dark:bg-[#454545] dark:text-white dark:ring-gray-500 ${
className={`w-56 origin-top-right rounded-md bg-gray-100 py-2 text-black shadow-sm ring-1 ring-inset ring-primary duration-100 dark:bg-gray-700 dark:text-white ${
isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
}`}
>

View File

@ -0,0 +1,36 @@
'use client';
import { apiFunctions } from '@/api';
import { Event } from '@/interfaces/event.interface';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { EventCard } from './event-card';
import { useAuthState } from '@/state/auth-state';
export function PrivateEvents() {
const [events, setEvents] = useState<Event[]>();
const getAccessToken = useAuthState((s) => s.getAccessToken);
useEffect(() => {
getAccessToken().then((token) => {
if (!token) return;
apiFunctions.getPrivateEvents(token).then((res) => setEvents(res.data));
});
}, []);
if ((events?.length ?? 0) === 0) return <></>;
return (
<div className="flex flex-col gap-2">
<h2 className="text-2xl">Private</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{events?.map((event, i) => (
<Link key={i} href={`/event/${event._id}`}>
<EventCard event={event} />
</Link>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,11 @@
import { faLock } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
export function PrivateTag() {
return (
<div className="dark:text-light inline-flex select-none items-center gap-1 rounded-full bg-gray-200 px-3 py-1 text-xs text-gray-600 dark:bg-gray-700">
<FontAwesomeIcon icon={faLock} />
<span>Private</span>
</div>
);
}

View File

@ -0,0 +1,26 @@
interface ProgressBarProps {
start: Date;
end: Date;
}
export function ProgressBar({ start, end }: ProgressBarProps) {
const progress = Math.max(
0,
Math.min(
((Date.now() - start.valueOf()) * 100) /
(end.valueOf() - start.valueOf()),
100,
),
).toFixed(1);
return (
<div className="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-primary"
style={{
width: `${progress}%`,
}}
/>
</div>
);
}

View File

@ -0,0 +1,7 @@
export interface CreateEventDto {
callsign: string;
description: string;
fromDateTime?: string;
toDateTime?: string;
isPrivate?: boolean;
}

View File

@ -4,9 +4,10 @@ export interface Event {
_id: string;
callsign: string;
description: string;
fromDateTime: Date;
toDateTime: Date;
access: User[];
fromDateTime?: Date;
toDateTime?: Date;
isPrivate: boolean;
access: string[];
createdAt: Date;
isDeleted: boolean;
}

View File

@ -14,9 +14,8 @@ const config: Config = {
},
extend: {
colors: {
primary: {
DEFAULT: '#E95635',
},
primary: '#2596be',
light: '#d1d5db',
},
},
},