mirror of
https://github.com/jakobkordez/ham-reserve.git
synced 2025-05-31 08:49:06 +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 {
|
export class CreateEventDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(3)
|
@IsUppercase()
|
||||||
|
@Matches(/^[A-Z\d]+\d+[A-Z]+$/, { message: 'Invalid callsign' })
|
||||||
callsign: string;
|
callsign: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
description: string;
|
description: string;
|
||||||
|
|
||||||
@IsDate()
|
@IsDateString()
|
||||||
|
@IsOptional()
|
||||||
fromDateTime: Date;
|
fromDateTime: Date;
|
||||||
|
|
||||||
@IsDate()
|
@IsDateString()
|
||||||
|
@IsOptional()
|
||||||
toDateTime: Date;
|
toDateTime: Date;
|
||||||
|
|
||||||
|
@IsBoolean()
|
||||||
|
@IsOptional()
|
||||||
|
isPrivate: boolean;
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { EventsService } from './events.service';
|
import { EventsService } from './events.service';
|
||||||
import { CreateEventDto } from './dto/create-event.dto';
|
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 { Event } from './schemas/event.schema';
|
||||||
import { Roles } from 'src/decorators/roles.decorator';
|
import { Roles } from 'src/decorators/roles.decorator';
|
||||||
import { Role } from 'src/enums/role.enum';
|
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')
|
@Controller('events')
|
||||||
export class EventsController {
|
export class EventsController {
|
||||||
@ -22,6 +26,11 @@ export class EventsController {
|
|||||||
@Roles(Role.Admin)
|
@Roles(Role.Admin)
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() createEventDto: CreateEventDto): Promise<Event> {
|
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);
|
return this.eventsService.create(createEventDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -31,14 +40,21 @@ export class EventsController {
|
|||||||
return this.eventsService.findAll();
|
return this.eventsService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('private')
|
||||||
|
findPrivate(@RequestUser() user: UserTokenData): Promise<Event[]> {
|
||||||
|
return this.eventsService.findPrivate(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Get()
|
@Get()
|
||||||
findCurrent(): Promise<Event[]> {
|
findCurrent(): Promise<Event[]> {
|
||||||
return this.eventsService.findCurrent();
|
return this.eventsService.findCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id', MongoIdPipe) id: string): Promise<Event> {
|
findOne(@Param('id', MongoIdPipe) id: string): Promise<Event> {
|
||||||
return this.eventsService.findOne(id);
|
return this.eventsService.findOne(id, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Roles(Role.Admin)
|
@Roles(Role.Admin)
|
||||||
@ -50,6 +66,24 @@ export class EventsController {
|
|||||||
return this.eventsService.update(id, updateEventDto);
|
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)
|
@Roles(Role.Admin)
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
remove(@Param('id', MongoIdPipe) id: string): Promise<Event> {
|
remove(@Param('id', MongoIdPipe) id: string): Promise<Event> {
|
||||||
|
@ -24,13 +24,42 @@ export class EventsService {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
return this.eventModel
|
return this.eventModel
|
||||||
.find({
|
.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();
|
.exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(id: string): Promise<Event> {
|
findPrivate(userId: string): Promise<Event[]> {
|
||||||
return this.eventModel.findById(id).exec();
|
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> {
|
update(id: string, updateEventDto: UpdateEventDto): Promise<Event> {
|
||||||
@ -39,6 +68,18 @@ export class EventsService {
|
|||||||
.exec();
|
.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> {
|
setDeleted(id: string): Promise<Event> {
|
||||||
return this.eventModel
|
return this.eventModel
|
||||||
.findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true })
|
.findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true })
|
||||||
|
@ -19,6 +19,11 @@ export class Event {
|
|||||||
@Prop()
|
@Prop()
|
||||||
toDateTime: Date;
|
toDateTime: Date;
|
||||||
|
|
||||||
|
@Prop({
|
||||||
|
default: false,
|
||||||
|
})
|
||||||
|
isPrivate: boolean;
|
||||||
|
|
||||||
@Prop({
|
@Prop({
|
||||||
type: [{ type: String, ref: User.name }],
|
type: [{ type: String, ref: User.name }],
|
||||||
default: [],
|
default: [],
|
||||||
|
@ -3,12 +3,15 @@ import {
|
|||||||
IsOptional,
|
IsOptional,
|
||||||
IsPhoneNumber,
|
IsPhoneNumber,
|
||||||
IsString,
|
IsString,
|
||||||
|
IsUppercase,
|
||||||
|
Matches,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class CreateUserDto {
|
export class CreateUserDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(3)
|
@IsUppercase()
|
||||||
|
@Matches(/^[A-Z\d]+\d+[A-Z]+$/, { message: 'Invalid callsign' })
|
||||||
username: string;
|
username: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@ -7,6 +7,8 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
|
ParseArrayPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service';
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
import { CreateUserDto } from './dto/create-user.dto';
|
||||||
@ -24,7 +26,12 @@ export class UsersController {
|
|||||||
|
|
||||||
@Roles(Role.Admin)
|
@Roles(Role.Admin)
|
||||||
@Post()
|
@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);
|
return this.usersService.create(createUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,6 +48,7 @@ export class UsersController {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(Role.Admin)
|
||||||
@Get('search/:username')
|
@Get('search/:username')
|
||||||
findByUsername(@Param('username') username: string): Promise<User> {
|
findByUsername(@Param('username') username: string): Promise<User> {
|
||||||
const user = this.usersService.findByUsername(username);
|
const user = this.usersService.findByUsername(username);
|
||||||
@ -48,11 +56,18 @@ export class UsersController {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Roles(Role.Admin)
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
findOne(@Param('id', MongoIdPipe) id: string): Promise<User> {
|
findOne(@Param('id', MongoIdPipe) id: string): Promise<User> {
|
||||||
return this.usersService.findOne(id);
|
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)
|
@Roles(Role.Admin)
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(
|
update(
|
||||||
|
@ -17,15 +17,21 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findAll(): Promise<User[]> {
|
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> {
|
findOne(id: string): Promise<User> {
|
||||||
return this.userModel.findById(id).exec();
|
return this.userModel.findById(id).exec();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findMany(ids: string[]): Promise<User[]> {
|
||||||
|
return this.userModel.find({ _id: ids }).exec();
|
||||||
|
}
|
||||||
|
|
||||||
findByUsername(username: string): Promise<User> {
|
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> {
|
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": {
|
"rules": {
|
||||||
"no-unused-vars": "warn"
|
"no-unused-vars": "warn"
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {}
|
const nextConfig = {
|
||||||
|
rewrites: async () => [{
|
||||||
|
source: '/api/:path*',
|
||||||
|
destination: 'http://localhost:3001/:path*' // Proxy to Backend
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = nextConfig
|
module.exports = nextConfig
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { User } from './interfaces/user.interface';
|
import { User } from './interfaces/user.interface';
|
||||||
import { CreateUserDto } from './interfaces/create-user-dto.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({
|
const api = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
@ -11,6 +13,11 @@ const api = axios.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use((originalResponse) => {
|
||||||
|
handleDates(originalResponse.data);
|
||||||
|
return originalResponse;
|
||||||
|
});
|
||||||
|
|
||||||
interface LoginResponse {
|
interface LoginResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
refreshToken: string;
|
refreshToken: string;
|
||||||
@ -58,6 +65,11 @@ export const apiFunctions = {
|
|||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
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) => {
|
updateUser: async (accessToken: string, id: string, user: User) => {
|
||||||
return await api.patch<User>(`/users/${id}`, user, {
|
return await api.patch<User>(`/users/${id}`, user, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
@ -70,7 +82,7 @@ export const apiFunctions = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
createEvent: async (accessToken: string, event: Event) => {
|
createEvent: async (accessToken: string, event: CreateEventDto) => {
|
||||||
return await api.post<Event>('/events', event, {
|
return await api.post<Event>('/events', event, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
@ -80,24 +92,56 @@ export const apiFunctions = {
|
|||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getCurrentEvents: async (accessToken: string) => {
|
getPrivateEvents: async (accessToken: string) => {
|
||||||
return await api.get<Event[]>('/events', {
|
return await api.get<Event[]>('/events/private', {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getEvent: async (accessToken: string, id: string) => {
|
getCurrentEvents: async () => {
|
||||||
return await api.get<Event>(`/events/${id}`, {
|
return await api.get<Event[]>('/events');
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
},
|
||||||
});
|
getEvent: async (id: string) => {
|
||||||
|
return await api.get<Event>(`/events/${id}`);
|
||||||
},
|
},
|
||||||
updateEvent: async (accessToken: string, id: string, event: Event) => {
|
updateEvent: async (accessToken: string, id: string, event: Event) => {
|
||||||
return await api.patch<Event>(`/events/${id}`, event, {
|
return await api.patch<Event>(`/events/${id}`, event, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
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) => {
|
deleteEvent: async (accessToken: string, id: string) => {
|
||||||
return await api.delete<Event>(`/events/${id}`, {
|
return await api.delete<Event>(`/events/${id}`, {
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
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() {
|
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}
|
key={href}
|
||||||
href={href}
|
href={href}
|
||||||
className={`rounded-md border border-gray-300 px-5 py-2 font-medium shadow dark:border-gray-600 ${
|
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/10 dark:bg-white/20'
|
||||||
: 'bg-black/5 hover:bg-black/10 dark:bg-white/10 dark:hover: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';
|
'use client';
|
||||||
|
|
||||||
import { apiFunctions } from '@/api';
|
import { apiFunctions } from '@/api';
|
||||||
import { useAuthState } from '@/state/auth-state';
|
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';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export function CreateUserForm() {
|
export function CreateUserForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
const getAccessToken = useAuthState((s) => s.getAccessToken);
|
const getAccessToken = useAuthState((s) => s.getAccessToken);
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
|
setError(undefined);
|
||||||
getAccessToken().then((accessToken) => {
|
getAccessToken().then((accessToken) => {
|
||||||
if (!accessToken) throw new Error('No access token');
|
if (!accessToken) {
|
||||||
|
setError('Session expired');
|
||||||
|
throw new Error('No access token');
|
||||||
|
}
|
||||||
apiFunctions
|
apiFunctions
|
||||||
.createUser(accessToken, {
|
.createUser(accessToken, {
|
||||||
username,
|
username: username.toUpperCase(),
|
||||||
password,
|
password,
|
||||||
name,
|
name,
|
||||||
email: email || undefined,
|
email: email || undefined,
|
||||||
@ -25,14 +36,13 @@ export function CreateUserForm() {
|
|||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
console.log(res);
|
console.log(res);
|
||||||
setUsername('');
|
router.push('/admin/users');
|
||||||
setPassword('');
|
|
||||||
setName('');
|
|
||||||
setEmail('');
|
|
||||||
setPhone('');
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="username"
|
id="username"
|
||||||
className="text-input"
|
className="text-input font-callsign"
|
||||||
placeholder="S50HQ"
|
placeholder="S50HQ"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
@ -94,6 +104,16 @@ export function CreateUserForm() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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}>
|
<button className="button" onClick={submit}>
|
||||||
Ustvari
|
Ustvari
|
||||||
</button>
|
</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';
|
'use client';
|
||||||
|
|
||||||
import { apiFunctions } from '@/api';
|
|
||||||
import { User } from '@/interfaces/user.interface';
|
import { User } from '@/interfaces/user.interface';
|
||||||
import { useAuthState } from '@/state/auth-state';
|
|
||||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { Dialog, Transition } from '@headlessui/react';
|
import { Dialog } from '@headlessui/react';
|
||||||
import { Fragment, useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
|
|
||||||
|
interface DeleteUserDialogProps {
|
||||||
|
user: User | undefined;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function DeleteUserDialog({
|
export function DeleteUserDialog({
|
||||||
user,
|
user,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: {
|
onConfirm,
|
||||||
user: User | undefined;
|
}: DeleteUserDialogProps) {
|
||||||
onCancel: () => void;
|
|
||||||
}) {
|
|
||||||
const getAccessToken = useAuthState((s) => s.getAccessToken);
|
|
||||||
|
|
||||||
const cancelButtonRef = useRef(null);
|
const cancelButtonRef = useRef(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -62,12 +62,7 @@ export function DeleteUserDialog({
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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 () => {
|
onClick={onConfirm}
|
||||||
const token = await getAccessToken();
|
|
||||||
if (!token) return;
|
|
||||||
apiFunctions.deleteUser(token, user!._id);
|
|
||||||
onCancel();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Deactivate
|
Deactivate
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,22 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useAuthState } from '@/state/auth-state';
|
import Link from 'next/link';
|
||||||
import NoSSR from 'react-no-ssr';
|
|
||||||
import { CreateUserForm } from './create-user-form';
|
|
||||||
import { UsersList } from './users-list';
|
import { UsersList } from './users-list';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faAdd } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
|
||||||
export default function AdminUsers() {
|
export default function AdminUsers() {
|
||||||
return (
|
return (
|
||||||
<>
|
<div>
|
||||||
<h2 className="text-2xl font-medium">Create user</h2>
|
<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>
|
<UsersList />
|
||||||
|
</div>
|
||||||
<NoSSR>
|
|
||||||
<UsersList />
|
|
||||||
</NoSSR>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,6 @@ import { faCrown, faTrash } from '@fortawesome/free-solid-svg-icons';
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { DeleteUserDialog } from './delete-user-dialog';
|
import { DeleteUserDialog } from './delete-user-dialog';
|
||||||
import { Dialog } from '@headlessui/react';
|
|
||||||
|
|
||||||
export function UsersList() {
|
export function UsersList() {
|
||||||
const [getAccessToken, getMe] = useAuthState((s) => [
|
const [getAccessToken, getMe] = useAuthState((s) => [
|
||||||
@ -20,66 +19,86 @@ export function UsersList() {
|
|||||||
const [me, setMe] = useState<User>();
|
const [me, setMe] = useState<User>();
|
||||||
const [deleteUser, setDeleteUser] = 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(() => {
|
useEffect(() => {
|
||||||
const f = async () => {
|
getUsers();
|
||||||
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();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!users || !me) return <div>Loading...</div>;
|
if (!users || !me) return <div>Loading...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
{users.map((user: User) => (
|
<div>
|
||||||
<div
|
{users.map((user: User) => (
|
||||||
key={user._id}
|
<div
|
||||||
className="flex flex-row border-b border-b-gray-500 px-4 py-2 last:border-b-0"
|
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}
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faCrown} className="h-5 w-5 leading-none" />
|
<div className="my-auto flex-1">
|
||||||
</button>
|
<div className="text-xl">
|
||||||
<button
|
<span className="font-callsign">
|
||||||
className={`h-14 w-14 rounded-full text-red-500 hover:bg-red-500/30 ${
|
{user.username.toUpperCase()}
|
||||||
user._id === me?._id ? 'invisible' : ''
|
</span>{' '}
|
||||||
}`}
|
- {user.name}
|
||||||
onClick={() => setDeleteUser(user)}
|
</div>{' '}
|
||||||
>
|
<div className="text-xs opacity-80">{user._id}</div>
|
||||||
<FontAwesomeIcon icon={faTrash} className="h-5 w-5 leading-none" />
|
</div>
|
||||||
</button>
|
<div className="my-auto flex-1">
|
||||||
</div>
|
<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
|
<DeleteUserDialog
|
||||||
user={deleteUser}
|
user={deleteUser}
|
||||||
onCancel={() => setDeleteUser(undefined)}
|
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 {
|
.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 {
|
.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 {
|
.header-button {
|
||||||
@apply h-full p-6 leading-none hover:bg-black/10 dark:hover:bg-white/20;
|
@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 { Header } from '@/components/header';
|
||||||
import { useThemeState } from '@/state/theme-state';
|
import { useThemeState } from '@/state/theme-state';
|
||||||
import { Inter } from 'next/font/google';
|
import { Allerta, Inter } from 'next/font/google';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const callsignFont = Allerta({
|
||||||
|
subsets: ['latin'],
|
||||||
|
variable: '--callsign-font',
|
||||||
|
weight: '400',
|
||||||
|
});
|
||||||
|
|
||||||
const inter = Inter({ subsets: ['latin'] });
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
export function LayoutComponent({ children }: { children: React.ReactNode }) {
|
export function LayoutComponent({ children }: { children: React.ReactNode }) {
|
||||||
@ -16,9 +22,9 @@ export function LayoutComponent({ children }: { children: React.ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className={theme}>
|
<html lang="sl" className={theme}>
|
||||||
<body
|
<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 />
|
<Header />
|
||||||
<main>{children}</main>
|
<main>{children}</main>
|
@ -1,6 +1,6 @@
|
|||||||
import './globals.scss';
|
import './globals.scss';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { LayoutComponent } from './layout_component';
|
import { LayoutComponent } from './layout-component';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: {
|
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 (
|
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]">
|
<div className="container py-10">
|
||||||
<h1 className="text-2xl font-bold">Prijava</h1>
|
<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">
|
||||||
<div className="flex flex-col gap-1">
|
<h1 className="text-2xl font-bold">Prijava</h1>
|
||||||
<label htmlFor="username">Uporabniško ime</label>
|
<div className="flex flex-col gap-1">
|
||||||
<input
|
<label htmlFor="username">Uporabniško ime</label>
|
||||||
id="username"
|
<input
|
||||||
type="username"
|
id="username"
|
||||||
className="text-input"
|
type="username"
|
||||||
value={username}
|
className="text-input font-callsign"
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
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>
|
||||||
<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>
|
</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';
|
import React from 'react';
|
||||||
|
|
||||||
const znaki = [
|
|
||||||
{
|
|
||||||
znak: 'S50YOTA',
|
|
||||||
od: '1. 12. 2023',
|
|
||||||
do: '31. 12. 2023',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<div className="container flex flex-col gap-10 py-8">
|
<div className="container flex flex-col gap-10 py-8">
|
||||||
<div>
|
<CurrentEvents />
|
||||||
<h2 className="mb-4 text-2xl">Trenutni znaki</h2>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4">
|
<PrivateEvents />
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h2 className="text-2xl">Kako do rezervacije?</h2>
|
<h2 className="text-2xl">Kako do rezervacije?</h2>
|
||||||
@ -69,7 +46,7 @@ interface TileProps {
|
|||||||
|
|
||||||
function Tile({ title, children, image }: TileProps) {
|
function Tile({ title, children, image }: TileProps) {
|
||||||
return (
|
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 className="mb-2 text-xl font-medium">{title}</div>
|
||||||
<div>{children}</div>
|
<div>{children}</div>
|
||||||
</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 (
|
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">
|
<Link href="/" className="my-auto ml-4 text-2xl font-semibold">
|
||||||
Ham Reserve
|
Ham Reserve
|
||||||
</Link>
|
</Link>
|
||||||
@ -60,7 +60,7 @@ function UserHeader() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<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'
|
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;
|
_id: string;
|
||||||
callsign: string;
|
callsign: string;
|
||||||
description: string;
|
description: string;
|
||||||
fromDateTime: Date;
|
fromDateTime?: Date;
|
||||||
toDateTime: Date;
|
toDateTime?: Date;
|
||||||
access: User[];
|
isPrivate: boolean;
|
||||||
|
access: string[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
isDeleted: boolean;
|
isDeleted: boolean;
|
||||||
}
|
}
|
||||||
|
@ -14,9 +14,8 @@ const config: Config = {
|
|||||||
},
|
},
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: '#2596be',
|
||||||
DEFAULT: '#E95635',
|
light: '#d1d5db',
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
x
Reference in New Issue
Block a user