mirror of
https://github.com/jakobkordez/ham-reserve.git
synced 2025-05-30 07:30:28 +00:00
WIP
This commit is contained in:
parent
08a27fb4fc
commit
eb29ab1b31
@ -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;
|
||||
}
|
||||
|
@ -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> {
|
||||
|
@ -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 })
|
||||
|
@ -19,6 +19,11 @@ export class Event {
|
||||
@Prop()
|
||||
toDateTime: Date;
|
||||
|
||||
@Prop({
|
||||
default: false,
|
||||
})
|
||||
isPrivate: boolean;
|
||||
|
||||
@Prop({
|
||||
type: [{ type: String, ref: User.name }],
|
||||
default: [],
|
||||
|
@ -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()
|
||||
|
@ -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(
|
||||
|
@ -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> {
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
141
next-app/src/app/admin/events/[id]/event-component.tsx
Normal file
141
next-app/src/app/admin/events/[id]/event-component.tsx
Normal 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>
|
||||
);
|
||||
}
|
40
next-app/src/app/admin/events/[id]/page.tsx
Normal file
40
next-app/src/app/admin/events/[id]/page.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
}
|
132
next-app/src/app/admin/events/create/create-event-form.tsx
Normal file
132
next-app/src/app/admin/events/create/create-event-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
11
next-app/src/app/admin/events/create/page.tsx
Normal file
11
next-app/src/app/admin/events/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
45
next-app/src/app/admin/events/events-list.tsx
Normal file
45
next-app/src/app/admin/events/events-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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'
|
||||
}`}
|
||||
|
@ -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>
|
11
next-app/src/app/admin/users/create/page.tsx
Normal file
11
next-app/src/app/admin/users/create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
99
next-app/src/app/event/[id]/event-component.tsx
Normal file
99
next-app/src/app/event/[id]/event-component.tsx
Normal 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>
|
||||
);
|
||||
}
|
44
next-app/src/app/event/[id]/page.tsx
Normal file
44
next-app/src/app/event/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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';
|
||||
|
@ -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>
|
@ -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: {
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
8
next-app/src/app/not-found.tsx
Normal file
8
next-app/src/app/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
33
next-app/src/components/current-events.tsx
Normal file
33
next-app/src/components/current-events.tsx
Normal 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>
|
||||
);
|
||||
}
|
33
next-app/src/components/event-card.tsx
Normal file
33
next-app/src/components/event-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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'
|
||||
}`}
|
||||
>
|
||||
|
36
next-app/src/components/private-events.tsx
Normal file
36
next-app/src/components/private-events.tsx
Normal 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>
|
||||
);
|
||||
}
|
11
next-app/src/components/private-tag.tsx
Normal file
11
next-app/src/components/private-tag.tsx
Normal 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>
|
||||
);
|
||||
}
|
26
next-app/src/components/progress-bar.tsx
Normal file
26
next-app/src/components/progress-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
7
next-app/src/interfaces/create-event-dto.interface.ts
Normal file
7
next-app/src/interfaces/create-event-dto.interface.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface CreateEventDto {
|
||||
callsign: string;
|
||||
description: string;
|
||||
fromDateTime?: string;
|
||||
toDateTime?: string;
|
||||
isPrivate?: boolean;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -14,9 +14,8 @@ const config: Config = {
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: '#E95635',
|
||||
},
|
||||
primary: '#2596be',
|
||||
light: '#d1d5db',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user