Compare commits

...

28 Commits

Author SHA1 Message Date
tylen
4ecf62faff implement wihslist and secret santa 2025-11-24 17:38:38 +02:00
tylen
06e146f1c4 implement wihslist and secret santa 2025-11-24 17:24:53 +02:00
tylen
5051abc440 frontend: make snowflakes slower 2025-11-03 12:27:21 +02:00
tylen
f69fa8b563 add attendnace table 2025-11-03 12:25:35 +02:00
tylen
4ff56ec06c backend: fix bool casting 2025-11-02 22:27:52 +02:00
tylen
3b6f544946 add upcoming features 2025-11-02 22:13:21 +02:00
tylen
dadb094017 frontend: add logout 2025-11-02 21:56:53 +02:00
tylen
27ee5d59c3 frontend: table addition elements refactor 2025-11-02 21:45:12 +02:00
tylen
c39384badf fix attendnace 2025-11-02 21:31:01 +02:00
tylen
c2e6f81775 finalize hosting 2025-11-02 15:42:34 +02:00
tylen
d1eeea0800 add reserve unreserve endpoints 2025-11-02 14:56:10 +02:00
tylen
cf9b0d53c1 frontend: add Loading 2025-11-02 13:50:44 +02:00
tylen
98e5ef06c0 frontend: create ayedance in greeting 2025-11-02 13:34:03 +02:00
tylen
0edcae1a24 frontend: add navbar 2025-11-02 13:02:45 +02:00
tylen
327ab8592a add attendance 2025-11-02 01:15:02 +02:00
tylen
9a192c9b61 frontend: add hosting tablr scroll indicator 2025-11-02 00:29:21 +02:00
tylen
ed5747e69b frontend: add snowflakes 2025-11-02 00:21:06 +02:00
tylen
1dfaa261e1 add styling 2025-11-02 00:05:03 +02:00
tylen
cef340a679 smplify token validation 2025-11-01 22:19:09 +02:00
tylen
b8a0fd9179 convert from connection to pools in mysql 2025-11-01 12:32:01 +02:00
tylen
014a8fc1ff make auth work correctly 2025-11-01 11:43:09 +02:00
tylen
a50a06d371 add frontend to docker compose 2025-10-31 17:30:31 +02:00
tylen
5baeee1f97 frontend: big upgrade 2025-10-31 17:30:08 +02:00
tylen
3f074e895d backend: implement users methods according to frontend 2025-10-31 17:29:33 +02:00
tylen
92c76d7155 frontend: create user auth 2025-10-31 15:02:28 +02:00
tylen
923adfc7dc frontend: add some styling 2025-10-30 22:25:48 +02:00
tylen
98175ede85 frontend: add program 2025-10-30 00:06:28 +02:00
tylen
8771e859fd hosting: fix notification on reserve 2025-10-29 16:09:41 +02:00
37 changed files with 2308 additions and 492 deletions

2
.gitignore vendored
View File

@@ -130,3 +130,5 @@ dist
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
frontend/src/assets/*

View File

@@ -1,3 +1,4 @@
flask==3.0.2 flask==3.0.2
mysql-connector-python==9.4.0 mysql-connector-python==9.4.0
python_dotenv==1.1.1 python_dotenv==1.1.1
Flask-CORS==6.0.1

View File

@@ -1,93 +0,0 @@
#!/usr/bin/env python
# encoding: utf-8
'''
car.py is a source for all car endpoints.
'''
from flask import request, jsonify
def registerCarEndpoints(app, database):
@app.route('/car', methods=['GET'])
def get_car():
if not request.is_json:
return jsonify({'error': 'Request must contain JSON data'}), 400
data = request.get_json()
if not data.get('name'):
return jsonify({'error': 'Request must contain name field'}), 400
query = f'SELECT * from car WHERE name = %s'
output = database.query(query_str=query, params=(data['name'],))
if not output:
return jsonify({"message": "No car by that name exist"}), 404
car = output[0]
if len(car) != 3:
return jsonify({'error': 'Car data is corrupted'}), 500
response = {
"name": car[0],
"car": car[1],
"freeCarSpaces": car[2]
}
return jsonify(response), 200
@app.route('/car', methods=['POST'])
def add_car():
if not request.is_json:
return jsonify({'error': 'Request must contain JSON data'}), 400
data = request.get_json()
if not data.get('name') or not data.get('car') or data.get('spaces') is None:
return jsonify({'error': 'JSON must contain car and name fields'}), 400
query = 'SELECT * from car WHERE name = %s'
output = database.query(query_str=query, params=(data['name'],))
if output:
return jsonify({'error': 'A person has a car already'}), 409
query = 'INSERT into car (Name, Car, FreeCarSpaces) VALUES (%s, %s, %s)'
output = database.query(query_str=query, params=(data['name'],data['car'],data['spaces']))
database.commit()
return jsonify({"message": "car added", "car": data}), 200
@app.route('/car', methods=['UPDATE'])
def update_car():
if not request.is_json:
return jsonify({'error': 'Request must contain JSON data'}), 400
data = request.get_json()
if not data.get('name') or not data.get('car') or data.get('spaces') is None:
return jsonify({'error': 'JSON must contain car,name,space fields'}), 400
query = 'SELECT * from car WHERE name = %s'
output = database.query(query_str=query, params=(data['name'],))
if not output:
return jsonify({'error': 'Such car does not exist. Add it first'}), 409
query = 'UPDATE car SET Name = %s, Car = %s, FreeCarSpaces = %s'
output = database.query(query_str=query, params=(data['name'],data['car'],data['spaces']))
database.commit()
return jsonify({"message": "car modified", "car": data}), 200
@app.route('/car', methods=['DELETE'])
def delete_car():
if not request.is_json:
return jsonify({'error': 'Request must contain JSON data'}), 400
data = request.get_json()
if not data.get('name'):
return jsonify({'error': 'JSON must contain persons name whose car to delete'}), 400
query = 'SELECT * from car WHERE name = %s'
output = database.query(query_str=query, params=(data['name'],))
if not output:
return jsonify({'error': 'Such person does not have a car'}), 409
query = 'DELETE FROM car WHERE Name = %s'
output = database.query(query_str=query, params=(data['name'],))
database.commit()
return jsonify({"message": "car deleted"}), 200

View File

@@ -1,115 +1,165 @@
#!/usr/bin/env python
# encoding: utf-8
'''
db_client.py is the module for managing teh Dungeon's database services.
'''
from enum import Enum from enum import Enum
import sys
import mysql.connector import mysql.connector
import os import os
import random
from mysql.connector import pooling
STARTUP_TABLE_CREATION_QUERIES = { STARTUP_TABLE_CREATION_QUERIES = {
"users": """CREATE TABLE IF NOT EXISTS users ( "users": """CREATE TABLE IF NOT EXISTS users (
Name varchar(255), Name varchar(255),
Attendance varchar(255), Attendance bool,
HasCar bool Password VARCHAR(2048),
WishListUrl VARCHAR(2048)
);""", );""",
"car": """CREATE TABLE IF NOT EXISTS car ( "sessions": """CREATE TABLE IF NOT EXISTS sessions (
Token VARCHAR(2048),
Name varchar(255)
);""",
"hosting": """CREATE TABLE IF NOT EXISTS hosting (
id SERIAL,
Name varchar(255), Name varchar(255),
Car varchar(255), Capacity INT,
FreeCarSpaces tinyint(1) reservedBy varchar(255)
);""", );""",
"suggestions": """CREATE TABLE IF NOT EXISTS suggestions ( "santa": """CREATE TABLE IF NOT EXISTS santa (
id INT PRIMARY KEY AUTO_INCREMENT,
Name VARCHAR(255),
Suggestion VARCHAR(2048)
);""",
"passengers": """CREATE TABLE IF NOT EXISTS passengers (
Name varchar(255), Name varchar(255),
Car varchar(255) Santa varchar(255)
);""", );""",
} }
INJECT_TABLE_CREATION_QUERIES = {
"hosting": """
INSERT INTO hosting (Name, Capacity, reservedBy)
SELECT name, capacity, reservedBy FROM (
SELECT 'Матрац 160см' AS name, 2 AS capacity, '' AS reservedBy
UNION ALL
SELECT 'Кровать 120см', 2, ''
UNION ALL
SELECT 'Матрац 90см', 1, ''
UNION ALL
SELECT 'Диван', 1, ''
) AS temp
WHERE NOT EXISTS (SELECT 1 FROM hosting WHERE Name = temp.name);
""",
}
class Severity(Enum): class Severity(Enum):
INFO = "INFO" INFO = "INFO"
WARNING = "WARNING" WARNING = "WARNING"
ERROR = "ERROR" ERROR = "ERROR"
class DBClient: class DBClient:
def __init__(self): def __init__(self, app):
self.app = app
self.db_server = os.environ.get('DB_SERVER') self.db_server = os.environ.get('DB_SERVER')
self.db_port = os.environ.get('DB_PORT') self.db_port = os.environ.get('DB_PORT')
self.user = 'root' self.user = 'root'
self.password = os.environ.get('ROOT_PWD') self.password = os.environ.get('ROOT_PWD')
self.database = os.environ.get('DB_NAME') self.database = os.environ.get('DB_NAME')
if not self.db_server: self.validate_env_variables() # Check for required environment variables
self.error("Environment variable 'DB_SERVER' is not set.") self.pool = self.create_pool() # Create a connection pool
if not self.db_port:
self.error("Environment variable 'DB_PORT' is not set.")
if not self.password:
self.error("Environment variable 'ROOT_PWD' is not set.")
if not self.database:
self.error("Environment variable 'DB_NAME' is not set.")
self.connection = mysql.connector.connect( self.initialize_database()
self.initialize_secret_santa() # Initialize Secret Santa
def validate_env_variables(self):
if not self.db_server or not self.db_port or not self.password or not self.database:
self.app.logger.error("Missing one or more environment variables.")
def create_pool(self):
return pooling.MySQLConnectionPool(
pool_name="mypool",
pool_size=5, # Adjust size as needed
host=self.db_server, host=self.db_server,
port=self.db_port, port=self.db_port,
user=self.user, user=self.user,
password=self.password, password=self.password,
database=self.database database=self.database,
connection_timeout=10 # Timeout in seconds
) )
self.cursor = self.connection.cursor()
self.initialize_database()
self.commit()
def create_database(self, db_name):
query = f"CREATE DATABASE IF NOT EXISTS `{db_name}`;"
self.cursor.execute(query)
def switch_database(self, db_name):
self.connection.database = db_name
def initialize_database(self): def initialize_database(self):
self.create_database(self.database)
self.switch_database(self.database)
self.query(STARTUP_TABLE_CREATION_QUERIES['users']) self.query(STARTUP_TABLE_CREATION_QUERIES['users'])
self.query(STARTUP_TABLE_CREATION_QUERIES['car']) self.query(STARTUP_TABLE_CREATION_QUERIES['sessions'])
self.query(STARTUP_TABLE_CREATION_QUERIES['suggestions']) self.query(STARTUP_TABLE_CREATION_QUERIES['hosting'])
self.query(STARTUP_TABLE_CREATION_QUERIES['passengers']) self.query(INJECT_TABLE_CREATION_QUERIES['hosting'])
def query(self, query_str, quiet=False, params=None): def initialize_secret_santa(self):
self.info(f'Executing query: {query_str}') table_exists = self.query("SHOW TABLES LIKE 'santa';")
self.cursor.execute(query_str, params)
if quiet:
return []
return self.cursor.fetchall()
def commit(self): if not table_exists:
self.info('Commiting actions to DB') self.query(STARTUP_TABLE_CREATION_QUERIES['santa'])
self.connection.commit()
def close(self): count_query = self.query('SELECT COUNT(*) FROM santa;')
self.cursor.close() if count_query[0][0] > 0:
self.connection.close() self.app.logger.warning('The santa table is not empty. No assignments will be made.')
return
def info(self, message): attendees = self.query('SELECT Name FROM users WHERE Attendance = 1;')
self.message(severity=Severity.INFO, message=message)
if not attendees:
return
attendees = [user[0] for user in attendees]
couples = [("Тюлень", "Тюлениха"), ("Медведь", "Ксения")]
max_attempts = 1000
for attempt in range(max_attempts):
shuffled_attendees = attendees.copy()
random.shuffle(shuffled_attendees)
santa_assignments = {}
valid = True
for index, user in enumerate(shuffled_attendees):
prev_user = shuffled_attendees[index - 1]
next_user = shuffled_attendees[(index + 1) % len(shuffled_attendees)]
if any(user in couple and (prev_user in couple or next_user in couple) for couple in couples):
valid = False
break
santa_assignments[user] = prev_user
if valid:
break
else:
self.app.logger.warning('Could not find valid Santa assignments after multiple attempts.')
return
self.app.logger.info(f'Santa assignments: {santa_assignments}')
for user, santa in santa_assignments.items():
self.query('INSERT INTO santa (Name, Santa) VALUES (%s, %s);', (user, santa))
def warning(self, message): def query(self, query_str, params=None):
self.message(severity=Severity.WARNING, message=message) max_retries = 3
for attempt in range(max_retries):
def error(self, message): try:
self.message(severity=Severity.ERROR, message=message) self.app.logger.info(f'Executing query: {query_str}')
sys.exit(1) # Get a connection from the pool
connection = self.pool.get_connection()
def message(self, severity, message): with connection.cursor() as cursor:
print(f'DBClient [{severity.value}]: {message}') cursor.execute(query_str, params)
if 'SELECT' in query_str or 'SHOW' in query_str:
results = cursor.fetchall()
self.app.logger.info(f'Query results: {results}')
return results
else:
connection.commit()
self.app.logger.info('Query executed successfully, changes committed.')
break
except mysql.connector.Error as e:
if e.errno in (2013, 2006): # Lost connection or connection no longer available
self.app.logger.warning(f"Lost connection to MySQL, retrying... {attempt + 1}/{max_retries}")
continue # Retry the query
else:
self.app.logger.error(f"Query failed: {str(e)}")
return None # Handle the error accordingly
finally:
if connection.is_connected():
connection.close() # Close the connection to return it to the pool

146
backend/src/hosting.py Normal file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python
# encoding: utf-8
'''
hosting.py is a source for all hosting endpoints.
'''
from flask import request, jsonify
import mysql.connector
def registerHostingEndpoints(app, database):
@app.route('/hosting', methods=['GET'])
def get_hosting():
try:
app.logger.info('Fetching hosting data')
query = "SELECT id, Name AS name, Capacity AS capacity, reservedBy FROM hosting"
result = database.query(query) # Adjust this depending on your database implementation
# Transform the result into the required structure
furniture_list = [
{
"id": row[0],
"name": row[1],
"capacity": row[2],
"reservedBy": row[3]
} for row in result
]
hosting_data = {
"furniture": furniture_list
}
app.logger.info(f'Fetched data: {hosting_data}')
return jsonify(hosting_data), 200
except mysql.connector.Error as err:
app.logger.error(f"Database error: {err}")
return jsonify({"error": "Database error occurred"}), 500
except Exception as e:
app.logger.error(f"Unexpected error: {e}")
return jsonify({"error": "Internal server error"}), 500
@app.route('/hosting/<int:id>', methods=['POST'])
def update_hosting(id):
data = request.get_json()
reserved_by = data.get('reservedBy')
if not reserved_by:
return jsonify({"error": "reservedBy field is required"}), 400
try:
app.logger.info(f'Updating hosting with ID {id} to reserved by {reserved_by}')
query = "UPDATE hosting SET reservedBy=%s WHERE id=%s"
params = (reserved_by, id)
database.query(query, params) # Adjust this depending on your database implementation
app.logger.info(f'Successfully updated hosting ID {id}')
return jsonify({"message": "Successfully updated"}), 200
except mysql.connector.Error as err:
app.logger.error(f"Database error: {err}")
return jsonify({"error": "Database error occurred"}), 500
except Exception as e:
app.logger.error(f"Unexpected error: {e}")
return jsonify({"error": "Internal server error"}), 500
@app.route('/hosting/<int:id>/unreserve', methods=['POST'])
def unreserve_item(id):
token = request.json.get('token')
if not token:
return jsonify(success=False, message="Token is required"), 400
query_session = "SELECT * FROM sessions WHERE Token=%s"
try:
result = database.query(query_session, params=(token,))
if not result:
return jsonify(success=False, message="Token is invalid or expired"), 401
user_name = result[0][1] # Assuming user name is in the second column of session result
# Check if the item is reserved by the user
reservation_check_query = """
SELECT * FROM hosting WHERE id = %s AND reservedBy = %s
"""
reservation_check_result = database.query(reservation_check_query, params=(id, user_name))
if not reservation_check_result:
return jsonify(success=False, message="Item is not reserved by you or does not exist"), 404
# Proceed to unreserve the item
unreserve_query = """
UPDATE hosting SET reservedBy = '' WHERE id = %s
"""
database.query(unreserve_query, params=(id,))
return jsonify(success=True, message="Item unreserved successfully"), 200
except Exception as e:
return jsonify(success=False, message=str(e)), 500
@app.route('/hosting/create', methods=['POST'])
def create_and_reserve_item():
token = request.json.get('token')
name = request.json.get('name') # Name of the item to create
capacity = request.json.get('capacity') # Capacity of the item
if not token:
return jsonify(success=False, message="Token is required"), 400
if not name:
return jsonify(success=False, message="Забыл имя"), 400
if capacity is None:
return jsonify(success=False, message="Забыл количество мест"), 400
query_session = "SELECT * FROM sessions WHERE Token=%s"
try:
# Verify user by token
result = database.query(query_session, params=(token,))
if not result:
return jsonify(success=False, message="Token is invalid or expired"), 401
user_name = result[0][1] # Assuming user name is in the second column of session result
# Check if the item already exists
item_check_query = "SELECT * FROM hosting WHERE Name = %s"
item_check_result = database.query(item_check_query, params=(name,))
if item_check_result: # If an item with the same name already exists
return jsonify(success=False, message="Уже существует"), 400
# Insert the new item and reserve it for the user
insert_query = """
INSERT INTO hosting (Name, Capacity, reservedBy)
VALUES (%s, %s, %s)
"""
params = (name, capacity, user_name) # Reserve the item to the user
database.query(insert_query, params=params)
return jsonify(success=True, message="Создано"), 201
except Exception as e:
return jsonify(success=False, message=str(e)), 500

View File

@@ -6,27 +6,25 @@ server.py is the main source file for the Dungeon's backend service.
''' '''
from flask import Flask, request, jsonify from flask import Flask, request, jsonify
from flask_cors import CORS
from dotenv import load_dotenv from dotenv import load_dotenv
from db_client import DBClient from db_client import DBClient
from car import registerCarEndpoints
from user import registerUserEndpoints from user import registerUserEndpoints
from suggestions import registerSuggestionsEndpoints from hosting import registerHostingEndpoints
import logging
load_dotenv() load_dotenv()
app = Flask(__name__) app = Flask(__name__)
database = DBClient() app.config['JSON_AS_ASCII'] = False # Ensures non-ASCII characters are preserved
registerCarEndpoints(app=app, database=database) logging.basicConfig(level=logging.INFO)
allowed_origins = [
"https://nyipyatki.davydovcloud.com",
"http://192.168.100.*",
]
CORS(app, resources={r"*": {"origins": allowed_origins}}) # Only allow example.com
database = DBClient(app)
registerUserEndpoints(app=app, database=database) registerUserEndpoints(app=app, database=database)
registerSuggestionsEndpoints(app=app, database=database) registerHostingEndpoints(app=app, database=database)
@app.route('/login', methods=['POST'])
def login():
if request.is_json:
return jsonify({"hello": "user"}), 200
else:
return jsonify({'error': 'Request must contain JSON data'}), 400
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True) app.run(debug=True)

View File

@@ -6,106 +6,223 @@ user.py is a source for all user endpoints.
''' '''
from flask import request, jsonify from flask import request, jsonify
import os
import mysql.connector
def registerUserEndpoints(app, database): def registerUserEndpoints(app, database):
@app.route('/users', methods=['GET']) @app.route('/users/isSet', methods=['GET'])
def get_users(): def user_is_set():
query = f'SELECT * from users' user_name = request.args.get('userName')
users = database.query(query_str=query) try:
if not users: app.logger.info(f'Searching for user {user_name}')
return jsonify({"message": "No users exist"}), 404 query = "SELECT * FROM users WHERE Name=%s"
response = {} result = database.query(query, params=(user_name,))
for user in users: app.logger.info(f'Got: {result}')
if len(user) != 3: return jsonify(bool(result)), 200
return jsonify({'error': 'User data is corrupted'}), 500 except mysql.connector.Error as err:
# Log the error or handle it as necessary
app.logger.error(f"Error: {err}")
return jsonify({"error": "Database error occurred"}), 500
except Exception as e:
# Handle unexpected errors
app.logger.error(f"Unexpected error: {e}")
return jsonify({"error": "Internal server error"}), 500 # Check if password exists
response.update({ @app.route('/users/createPassword', methods=['POST'])
"name": user[0], def create_password():
"attendance": user[1], data = request.json
"has_car": bool(user[2]) user_name = data.get('userName')
}) password = data.get('password')
return jsonify(response), 200
@app.route('/user', methods=['GET']) # Check if the user already exists
def get_user(): query = "SELECT * FROM users WHERE Name=%s"
if not request.is_json: result = database.query(query, params=(user_name,))
return jsonify({'error': 'Request must contain JSON data'}), 400
data = request.get_json() if result:
return jsonify(success=False, message='Пользователь уже создан'), 400
if not data.get('name'): query = "INSERT INTO users (Name, Password) VALUES (%s, %s)"
return jsonify({'error': 'Request must contain name field'}), 400
query = f'SELECT * from users WHERE name = %s' try:
output = database.query(query_str=query, params=(data['name'],)) database.query(query, params=(user_name, password))
if not output:
return jsonify({"message": "No user by that name exist"}), 404
user = output[0]
if len(user) != 3:
return jsonify({'error': 'User data is corrupted'}), 500
response = { # Generate a session token
"name": user[0], token = os.urandom(16).hex()
"attendance": user[1], session_query = "INSERT INTO sessions (Token, Name) VALUES (%s, %s)"
"has_car": bool(user[2]) database.query(session_query, params=(token,user_name))
}
return jsonify(response), 200
@app.route('/user', methods=['POST']) return jsonify(success=True, token=token), 201 # Return success with token
def add_user(): except Exception as e:
if not request.is_json: return jsonify(success=False, message='Ошибка при создании пароля: ' + str(e)), 500
return jsonify({'error': 'Request must contain JSON data'}), 400
data = request.get_json()
if not data.get('name') or not data.get('attendance') or data.get('has_car') is None:
return jsonify({'error': 'JSON must contain user fields'}), 400
query = 'SELECT * from users WHERE name = %s' @app.route('/login', methods=['POST'])
output = database.query(query_str=query, params=(data['name'],)) def login():
if output: data = request.json
return jsonify({'error': 'A person already exists'}), 409 user_name = data.get('userName')
password = data.get('password')
query = 'INSERT into users (Name, Attendance, HasCar) VALUES (%s, %s, %s)' query = "SELECT * FROM users WHERE Name=%s AND Password=%s"
output = database.query(query_str=query, params=(data['name'],data['attendance'],data['has_car'])) result = database.query(query, params=(user_name, password))
database.commit() if result:
return jsonify({"message": "user added", "user": data}), 200 token = os.urandom(16).hex() # Example token generation
session_query = "INSERT INTO sessions (Token, Name) VALUES (%s, %s)"
database.query(session_query, params=(token, user_name))
return jsonify(success=True, token=token), 200
@app.route('/user', methods=['UPDATE']) return jsonify(success=False), 401
def update_user():
if not request.is_json:
return jsonify({'error': 'Request must contain JSON data'}), 400
data = request.get_json() @app.route('/login/validateToken', methods=['POST'])
if not data.get('name') or not data.get('attendance') or data.get('has_car') is None: def validate_token():
return jsonify({'error': 'JSON must contain user fields'}), 400 data = request.json
token = data.get('token')
query = "SELECT * FROM sessions WHERE Token=%s"
try:
result = database.query(query, params=(token,))
app.logger.info(f'Got result: {result}')
return jsonify(userName=result[0][1], tokenValid=True), 200
except Exception as e:
return jsonify(success=False, message=str(e)), 500
query = 'SELECT * from users WHERE name = %s' @app.route('/users/attendance', methods=['PUT'])
output = database.query(query_str=query, params=(data['name'],)) def update_attendance():
if not output: data = request.json
return jsonify({'error': 'Such user does not exist. Add it first'}), 409 token = data.get('token')
attendance_status = data.get('attendance') # Get attendance status from the request data
query = 'UPDATE user SET Name = %s, Attendance = %s, HasCar = %s' if attendance_status is None:
output = database.query(query_str=query, params=(data['name'],data['attendance'],data['has_car'])) return jsonify(success=False, message="Attendance status is required"), 400
database.commit() query_session = "SELECT * FROM sessions WHERE Token=%s"
return jsonify({"message": "user modified", "user": data}), 200 try:
result = database.query(query_session, params=(token,))
if not result:
return jsonify(success=False, message="Token is invalid or expired"), 401
@app.route('/user', methods=['DELETE']) user_name = result[0][1]
def delete_user(): attendance_query = "UPDATE users SET Attendance = %s WHERE Name = %s"
if not request.is_json: update_result = database.query(attendance_query, params=(attendance_status, user_name))
return jsonify({'error': 'Request must contain JSON data'}), 400
data = request.get_json() return jsonify(success=True, message="Attendance updated successfully"), 200
if not data.get('name'):
return jsonify({'error': 'JSON must contain persons name to delete'}), 400
query = 'SELECT * from users WHERE name = %s' except Exception as e:
output = database.query(query_str=query, params=(data['name'],)) return jsonify(success=False, message=str(e)), 500
if not output:
return jsonify({'error': 'Such person does not exist'}), 409
query = 'DELETE FROM users WHERE Name = %s' @app.route('/users/attendance', methods=['GET'])
output = database.query(query_str=query, params=(data['name'],)) def get_attendance():
database.commit() token = request.args.get('token')
return jsonify({"message": "user deleted"}), 200
if not token:
return jsonify(success=False, message="Token is required"), 400
query_session = "SELECT * FROM sessions WHERE Token=%s"
try:
result = database.query(query_session, params=(token,))
if not result:
return jsonify(success=False, message="Token is invalid or expired"), 401
user_name = result[0][1]
attendance_query = "SELECT Attendance FROM users WHERE Name = %s"
attendance_result = database.query(attendance_query, params=(user_name,))
if not attendance_result:
return jsonify(success=False, message="User not found"), 404
attendance_status = attendance_result[0][0]
return jsonify(success=True, attendance=attendance_status), 200
except Exception as e:
return jsonify(success=False, message=str(e)), 500
@app.route('/users/attendance/all', methods=['GET'])
def get_attendance_all():
token = request.args.get('token')
if not token:
return jsonify(success=False, message="Token is required"), 400
query_session = "SELECT * FROM sessions WHERE Token=%s"
try:
result = database.query(query_session, params=(token,))
if not result:
return jsonify(success=False, message="Token is invalid or expired"), 401
attendance_query = "SELECT Name, Attendance FROM users"
attendance_result = database.query(attendance_query)
if not attendance_result:
return jsonify(success=False, message="No users found"), 404
attendance_list = [{"name": row[0], "attendance": row[1]} for row in attendance_result]
return jsonify(success=True, attendance_list=attendance_list), 200
except Exception as e:
return jsonify(success=False, message=str(e)), 500
@app.route('/users/wishlist', methods=['PUT'])
def update_wishlist():
data = request.json
token = data.get('token')
wishlist_url = data.get('wishlist')
if wishlist_url is None:
return jsonify(success=False, message="Wishlist URL is required"), 400
query_session = "SELECT * FROM sessions WHERE Token=%s"
try:
result = database.query(query_session, params=(token,))
if not result:
return jsonify(success=False, message="Token is invalid or expired"), 401
user_name = result[0][1]
wishlist_query = "UPDATE users SET WishListUrl = %s WHERE Name = %s"
update_result = database.query(wishlist_query, params=(wishlist_url, user_name))
return jsonify(success=True, message="WishListUrl updated successfully"), 200
except Exception as e:
return jsonify(success=False, message=str(e)), 500
@app.route('/users/santa', methods=['GET'])
def get_santainfo():
token = request.args.get('token')
if not token:
return jsonify(success=False, message="Token is required"), 400
query_session = "SELECT * FROM sessions WHERE Token=%s"
try:
result = database.query(query_session, params=(token,))
if not result:
return jsonify(success=False, message="Token is invalid or expired"), 401
user_name = result[0][1]
santa_query = "SELECT Name FROM santa WHERE Santa = %s"
santa_result = database.query(santa_query, params=(user_name,))
if not santa_result:
return jsonify(success=False, message=f"User's {user_name} Santa info not found"), 404
santa_to = santa_result[0][0]
wishlist_query = "SELECT WishListUrl FROM users WHERE Name = %s"
wishlist_result = database.query(wishlist_query, params=(santa_to,))
santa_info = {
"santa_to": santa_to,
"wishlist": wishlist_result[0][0]
}
return jsonify(success=True, santa_info=santa_info), 200
except Exception as e:
return jsonify(success=False, message=str(e)), 500

View File

@@ -3,9 +3,10 @@ services:
build: build:
context: backend context: backend
dockerfile: Dockerfile dockerfile: Dockerfile
restart: always
ports: ports:
- "2027:5000" - "2027:5000"
container_name: "${CONTAINER_NAME:-nyi-backend}" container_name: "${BACKEND_CT_NAME:-nyi-backend}"
depends_on: depends_on:
- db # Ensure backend waits for db to start - db # Ensure backend waits for db to start
db: db:
@@ -18,6 +19,14 @@ services:
- MYSQL_DATABASE=${DB_NAME} - MYSQL_DATABASE=${DB_NAME}
volumes: volumes:
- nyi_db_volume:/var/lib/mysql - nyi_db_volume:/var/lib/mysql
frontend:
build:
context: frontend
dockerfile: Dockerfile
ports:
- "2028:80"
container_name: "${FRONTEND_CT_NAME:-nyi-frontend}"
depends_on:
- backend # Ensure fronetnd waits for backend to start
volumes: volumes:
nyi_db_volume: nyi_db_volume:

29
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# Use the official Node.js image.
FROM node:18 AS build
# Set the working directory
WORKDIR /app
# Copy package.json and package-lock.json (or yarn.lock) to the working directory
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application
COPY . .
# Build the application
RUN npm run build
# Start a new stage to serve the application
FROM nginx:alpine
# Copy the build output to Nginx's public folder
COPY --from=build /app/dist /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Start Nginx when the container runs
CMD ["nginx", "-g", "daemon off;"]

2
frontend/fonts.css Normal file
View File

@@ -0,0 +1,2 @@

View File

@@ -8,11 +8,15 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"react": "^19.1.1", "crypto-js": "^4.2.0",
"react-dom": "^19.1.1" "js-cookie": "^3.0.5",
"react": "^19.2.0",
"react-cookie": "^8.0.1",
"react-dom": "^19.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@types/crypto-js": "^4.2.2",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
@@ -1406,6 +1410,13 @@
"@babel/types": "^7.28.2" "@babel/types": "^7.28.2"
} }
}, },
"node_modules/@types/crypto-js": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz",
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1413,6 +1424,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz",
"integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==",
"license": "MIT",
"dependencies": {
"hoist-non-react-statics": "^3.3.0"
},
"peerDependencies": {
"@types/react": "*"
}
},
"node_modules/@types/json-schema": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1424,7 +1447,6 @@
"version": "19.1.12", "version": "19.1.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -1941,6 +1963,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1956,11 +1987,16 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@@ -2431,6 +2467,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"license": "BSD-3-Clause",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2508,6 +2553,15 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2883,26 +2937,46 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.1.1", "version": "19.2.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-dom": { "node_modules/react-cookie": {
"version": "19.1.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-8.0.1.tgz",
"integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", "integrity": "sha512-QNdAd0MLuAiDiLcDU/2s/eyKmmfMHtjPUKJ2dZ/5CcQ9QKUium4B3o61/haq6PQl/YWFqC5PO8GvxeHKhy3GFA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"scheduler": "^0.26.0" "@types/hoist-non-react-statics": "^3.3.6",
"hoist-non-react-statics": "^3.3.2",
"universal-cookie": "^8.0.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.1.1" "react": ">= 16.3.0"
} }
}, },
"node_modules/react-dom": {
"version": "19.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3000,9 +3074,9 @@
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.26.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/semver": { "node_modules/semver": {
@@ -3075,14 +3149,14 @@
} }
}, },
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.14", "version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fdir": "^6.4.4", "fdir": "^6.5.0",
"picomatch": "^4.0.2" "picomatch": "^4.0.3"
}, },
"engines": { "engines": {
"node": ">=12.0.0" "node": ">=12.0.0"
@@ -3199,6 +3273,15 @@
"typescript": ">=4.8.4 <6.0.0" "typescript": ">=4.8.4 <6.0.0"
} }
}, },
"node_modules/universal-cookie": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-8.0.1.tgz",
"integrity": "sha512-B6ks9FLLnP1UbPPcveOidfvB9pHjP+wekP2uRYB9YDfKVpvcjKgy1W5Zj+cEXJ9KTPnqOKGfVDQBmn8/YCQfRg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.2"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
@@ -3241,9 +3324,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.3", "version": "7.1.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3252,7 +3335,7 @@
"picomatch": "^4.0.3", "picomatch": "^4.0.3",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"rollup": "^4.43.0", "rollup": "^4.43.0",
"tinyglobby": "^0.2.14" "tinyglobby": "^0.2.15"
}, },
"bin": { "bin": {
"vite": "bin/vite.js" "vite": "bin/vite.js"

View File

@@ -10,11 +10,15 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"react": "^19.1.1", "crypto-js": "^4.2.0",
"react-dom": "^19.1.1" "js-cookie": "^3.0.5",
"react": "^19.2.0",
"react-cookie": "^8.0.1",
"react-dom": "^19.2.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.33.0", "@eslint/js": "^9.33.0",
"@types/crypto-js": "^4.2.2",
"@types/react": "^19.1.10", "@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",

View File

@@ -1,11 +1,319 @@
#root { /* fonts.css */
width: 100%; @font-face {
max-width: 1280px; font-family: 'Plovdiv';
min-width: 375px; /* Font family name */
margin: 0 auto; src: url('./assets/fonts/Plovdiv/PlovdivDisplay-Bold.otf') format('opentype');
text-align: center; /* Regular style */
font-weight: bold;
/* Standard weight */
font-style: normal;
/* Standard style */
} }
.mainText { @font-face {
color: #5f5e5e; font-family: 'Plovdiv';
/* Same font family name */
src: url('./assets/fonts/Plovdiv/PlovdivDisplay-Regular.otf') format('opentype');
/* Bold style */
font-weight: normal;
/* Bold weight */
font-style: normal;
/* Standard style */
}
@font-face {
font-family: 'Pomelo';
/* Name of the font */
src: url('./assets/fonts/Pomelo/PomeloRegular.ttf') format('truetype');
/* Path to the TTF file */
font-weight: normal;
/* Standard weight */
font-style: normal;
/* Standard style */
}
#root {
width: 100%;
text-align: center;
/* Centered text alignment */
}
/* Overall Body Style */
body {
background-color: #ffffff;
/* Light background for contrast */
background-image: url('./assets/snowflakes.png');
/* Background image */
background-size: cover;
/* Cover the entire screen */
background-position: center;
/* Center the image */
background-repeat: no-repeat;
/* Prevent repeating */
color: #ffffff;
/* Darker red brown for readability */
font-family: 'Plovdiv', sans-serif;
font-size: 1.5em;
margin: 0;
text-align: center;
/* Centered text alignment */
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
}
.scroll-instruction {
display: none;
}
@media (max-width: 768px) {
body {
font-size: 1em;
}
}
@media (max-width: 375px) {
.scroll-instruction {
display: block;
}
}
/* Header Style */
header {
background-color: #a41e34;
/* Christmas red */
color: white;
padding: 20px 0;
border-bottom: 5px solid #b7c9b5;
/* Light green border */
}
header h1 {
font-size: 2.5em;
}
h1 {
font-family: 'Pomelo', sans-serif;
}
a {
color: #ffffff;
}
/* Section Style */
section {
background-color: #e4f7e0;
/* Light green background */
padding: 20px;
border-radius: 10px;
margin: 15px 0;
}
/* Festive Buttons */
button {
background-color: #b77de5;
/* Purple with a slight festive flair */
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 1.1em;
display: inline-block;
/* Centers button in the container */
}
button:hover {
background-color: #9b6bb5;
/* Darker purple on hover */
}
button:disabled {
background-color: #d3d3d3;
/* Light gray for disabled button */
color: #a9a9a9;
/* Darker gray for text */
cursor: not-allowed;
/* Show not-allowed cursor */
}
/* Footer Style */
footer {
text-align: center;
/* Centered */
padding: 20px;
background-color: #a41e34;
/* Same red as header */
color: white;
position: relative;
}
.table-wrapper {
width: 100%;
/* Full width of the viewport */
text-align: center;
/* Center the text (optional for non-text elements) */
overflow-x: auto;
/* Enables horizontal scrolling if necessary */
}
/* General table styles */
table {
width: 100%;
/* Set table width to 80% to create space on sides */
max-width: 100%;
/* Prevents exceeding the parent's width */
color: black;
border-collapse: collapse;
text-align: center;
/* Centered text in table */
margin: 0 auto;
/* Center the table within the wrapper */
}
/* Table header styles */
th {
background-color: #060698;
/* Christmas red */
color: white;
padding: 10px 15px;
border-bottom: 3px solid #e0e9f7;
/* Light green border for header */
}
/* Table cell styles */
td {
border: 1px solid #ddd;
padding: 10px 15px;
/* Added padding for better spacing */
background-color: #e0e9f7;
/* Light green background for cells */
}
/* Zebra striping for table rows */
tr:nth-child(even) {
background-color: #f9f9f9;
/* Light gray for even rows */
}
/* Hover effect for rows */
tr:hover {
background-color: #c28f8f;
/* Softer red when hovering */
}
/* Additional table styling for Christmas theme */
table {
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
/* Shadow for depth */
border-radius: 8px;
/* Rounded corners */
overflow: hidden;
/* Ensures border radius works */
}
/* Add some festive decorations */
th::after {
content: '🎄';
/* Small Christmas tree icon in the header */
margin-left: 10px;
font-size: 1.2em;
/* Slightly larger */
}
td {
position: relative;
/* For potential decorations */
}
td::before {
content: '';
/* Placeholder for decoration */
position: absolute;
background-image: url('path/to/snowflake-icon.png');
/* Snowflake icon */
width: 16px;
/* Adjust as necessary */
height: 16px;
opacity: 0.1;
/* Very subtle */
top: 5px;
/* Position it nicely */
left: 5px;
pointer-events: none;
/* Ignore mouse events */
}
nav {
top: 0; /* Align it to the top */
background-color: rgba(0, 42, 255, 0.6);; /* Background color */
border-radius: 20px;
z-index: 1000; /* Make sure it stays above other content */
padding: 1rem;
}
nav ul {
list-style-type: none;
padding: 0;
margin: 0;
display: flex; /* Use flex to align items */
justify-content: space-around; /* Space the items evenly */
}
nav ul li p {
color: white;
text-decoration: none;
}
nav ul li p:hover {
text-decoration: underline;
}
nav li {
display: inline;
}
nav a {
color: white;
text-decoration: none;
}
nav a:hover {
text-decoration: underline;
}
.nav-toggle {
display: none;
}
@media (max-width: 768px) {
.nav-toggle {
display: block;
background-color: rgba(0, 42, 255, 0.6); /* Button color */
border: none;
padding: 10px 20px;
cursor: pointer;
margin-bottom: 20px;
}
nav {
display: none; /* Hide by default for mobile */
flex-direction: column; /* Stack vertically */
}
nav.open {
display: flex; /* Show when open */
}
nav ul {
flex-direction: column; /* Stack menu items */
align-items: center;
}
nav li {
margin: 10px 0; /* Space between items */
}
} }

View File

@@ -1,15 +1,89 @@
import './App.css' import { useState } from 'react';
import Greeting from './components/Greeting' import './App.css';
import Hosting from './components/Hosting' import Greeting from './components/Greeting';
import Hosting from './components/Hosting';
import InitialSetup from './components/InitialSetup';
import Program from './components/Program';
import Snowflakes from './components/Snowflakes';
import { FullScreenLoading } from './components/Loading';
import SecretSanta from './components/SecretSanta';
import Suggestions from './components/Suggestions';
import AttendanceTable from './components/AttendnaceTable';
function App() { function App() {
const [isNavOpen, setIsNavOpen] = useState(false);
const handleLogout = () => {
const cookies = document.cookie.split(";");
for (let cookie of cookies) {
const cookieName = cookie.split("=")[0].trim();
// Set the cookie's expiration date to the past to delete it
document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/;`;
}
window.location.reload();
};
const toggleNav = () => {
setIsNavOpen(!isNavOpen);
};
return ( return (
<> <>
<Greeting/> <FullScreenLoading />
<br/> <Snowflakes />
<Hosting/> <InitialSetup />
<button onClick={toggleNav} className="nav-toggle">
{isNavOpen ? '❌' : 'Меню 📂'}
</button>
<nav className={isNavOpen ? 'open' : ''}>
<ul>
<li>
<a href="#greeting">Приглашение</a>
</li>
<li>
<a href="#hosting">Поселение</a>
</li>
<li>
<a href="#program">Программа</a>
</li>
<li>
<a href="#suggestions">Пожелания</a>
</li>
<li>
<a href="#santa">Secret Santa</a>
</li>
<li>
<a href="#attendance-table">Кто празднует?</a>
</li>
<li>
<button onClick={() => handleLogout()}>Выйти</button>
</li>
</ul>
</nav>
<div id="greeting">
<Greeting />
</div>
<div id="hosting">
<Hosting />
</div>
<div id="program">
<Program />
</div>
<div id="suggestions">
<Suggestions />
</div>
<div id="santa">
<SecretSanta />
</div>
<div id="attendance-table">
<AttendanceTable />
</div>
</> </>
) );
} }
export default App export default App;

View File

@@ -0,0 +1,81 @@
// src/NotificationContext.tsx
import React, { createContext, useContext, useState } from 'react';
export type NotificationType = 'success' | 'error';
interface NotificationContextType {
notify: (message: string, type: NotificationType) => void;
}
const NotificationContext = createContext<NotificationContextType | undefined>(undefined);
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [notification, setNotification] = useState<string | null>(null);
const [notificationType, setNotificationType] = useState<NotificationType | null>(null);
const notify = (message: string, type: NotificationType) => {
setNotification(message);
setNotificationType(type);
// Automatically dismiss notification after 3 seconds
setTimeout(() => {
setNotification(null);
setNotificationType(null);
}, 3000);
};
return (
<NotificationContext.Provider value={{ notify }}>
{children}
{notification && (
<Notification message={notification} onClose={() => setNotification(null)} type={notificationType!} />
)}
</NotificationContext.Provider>
);
};
export const useNotification = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotification must be used within a NotificationProvider');
}
return context.notify;
};
interface NotificationProps {
message: string;
onClose: () => void;
type: 'success' | 'error'; // Define notification types
}
// Notification Component
const Notification: React.FC<NotificationProps> = ({ message, onClose, type }) => {
return (
<div style={{ ...styles.notification, backgroundColor: type === 'success' ? '#4caf50' : '#f44336' }}>
<p>{message}</p>
<button onClick={onClose} style={styles.closeButton}>X</button>
</div>
);
};
// Notification styles remain the same...
const styles: { [key: string]: React.CSSProperties } = {
notification: {
position: 'fixed',
top: '20px',
right: '20px',
color: 'white',
padding: '15px',
borderRadius: '5px',
zIndex: 1000,
},
closeButton: {
background: 'transparent',
border: 'none',
color: 'white',
cursor: 'pointer',
marginLeft: '10px',
},
};

View File

@@ -0,0 +1,42 @@
import React from 'react';
import useFetchUser from '../utils/fetchUser';
const ApologyMessage: React.FC = () => {
const { updateAttendance } = useFetchUser()
const handleButtonClick = async () => {
await updateAttendance(true)
window.location.reload();
};
return (
<div style={styles.container}>
<p>
Нам очень жаль, что ты в этот раз не будешь с нами... Но может ты еще поменяешь свое мнение
</p>
<button onClick={handleButtonClick}>
Изменить мнение
</button>
</div>
);
};
const styles = {
container: {
position: 'fixed' as 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0, 0, 0, 1)',
display: 'flex',
flexDirection: 'column' as 'column',
justifyContent: 'center',
alignItems: 'center',
color: '#fff',
zIndex: 1000,
overflow: 'hidden',
}
}
// Export the component
export default ApologyMessage;

View File

@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import useFetchUser from "../utils/fetchUser";
import CenteredContainer from "./ChildrenContainer";
import type { User } from "../types";
const processAttendance = (attendance: boolean | null): string => {
if (attendance == null) {
return "Пока не ответил"
}
return (attendance == true) ? "Да!" : "Не в этот раз"
}
function AttendanceTable() {
const {getAttendanceAll} = useFetchUser()
const [userAttendanceData, setUserAttendnaceData] = useState<User[]>([])
const fetchUserData = async () => {
const userData = await getAttendanceAll()
setUserAttendnaceData(userData)
}
const handleRefresh = () => {
fetchUserData()
}
useEffect(() => {
fetchUserData()
}, [])
return (
<>
<CenteredContainer>
<h2>Кто празднует?</h2>
{userAttendanceData && (
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Пятка</th>
<th>Празднует с нами?</th>
</tr>
</thead>
<tbody>
{userAttendanceData && userAttendanceData.map((item) => (
<tr>
<td>{item.name}</td>
<td>{processAttendance(item.attendance)}</td>
</tr>
))}
</tbody>
</table>
<p className="scroll-instruction">Таблицу можно скроллить</p>
</div>
)}
<br/>
<button onClick={handleRefresh}>Обновить</button>
</CenteredContainer>
</>
)
}
export default AttendanceTable;

View File

@@ -0,0 +1,26 @@
.centered-container {
display: flex; /* Use Flexbox for centering */
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically */
/* height: 100vh; Full height of the viewport */
width: 100%; /* Full width of the viewport */
overflow-x: auto; /* Enables horizontal scrolling if necessary */
margin: 20px 0; /* Spacing above and below the container */
background-color: rgba(0, 42, 255, 0.6);
border-radius: 20px;
}
.inner-container {
width: 80%; /* Set inner container width to 80% */
max-width: 1000px; /* Optional: limit maximum width */
box-sizing: border-box; /* Include padding/borders in width calculation */
padding: 20px; /* Padding for inner content */
text-align: center; /* Center text inside the inner container */
}
/* Media query for responsive design */
@media (max-width: 768px) {
.inner-container {
width: 100%; /* Set inner container width to 100% on mobile */
}
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
import './CenteredConatiner.css'
interface CenteredContainerProps {
children: React.ReactNode; // Define children prop type
}
const CenteredContainer: React.FC<CenteredContainerProps> = ({ children }) => {
return (
<div className="centered-container">
<div className="inner-container">
{children}
</div>
</div>
);
};
export default CenteredContainer;

View File

@@ -1,16 +1,82 @@
function Greeting() { import { useCookies } from "react-cookie";
import CenteredContainer from "./ChildrenContainer";
import useFetchUser from "../utils/fetchUser";
import type React from "react";
import ApologyMessage from "./Attendance";
import { useState } from "react";
const Attendance: React.FC = () => {
const { updateAttendance, getAttendance } = useFetchUser()
const [attendance, setAttendance] = useState<boolean | null>(null)
const fetchAttendance = async () => {
const is = await getAttendance()
setAttendance(is)
}
fetchAttendance()
const handleUpdate = async (status: boolean) => {
await updateAttendance(status)
window.location.reload();
}
if (attendance == false) {
return (<ApologyMessage/>)
}
return ( return (
<> <>
<h1>Приглашение на Новый год 2025-2026 🎄</h1> {attendance == null && (
<p className="mainText"> <>
Дорогие, Пятки! 🦶 <h3>Присоеденишься?</h3>
<p>(Можно и потом ответить)</p>
<button style={localStyles.buttonOk} onClick={() => handleUpdate(true)}>Да!</button>
<button style={localStyles.buttonNok} onClick={() => handleUpdate(false)}>Не смогу в этот раз</button>
</>
)}
{attendance == true && (
<>
<h3>Круто! Ты с нами!</h3>
<p>Если все же по разным обстоятельствам ты не сможешь/не захочешь, то всегда можно передумать</p>
<button style={localStyles.buttonNok} onClick={() => handleUpdate(false)}>Все же никак</button>
</>)}
</>)
}
Приглашаем вас отпраздновать предстоящий Новый Год <b>2025-2026</b> с нами в сосновой избе, в которой, ко всему прочему, будет праздноваться годовщина нашей жизни в ней! function Greeting() {
const [cookie] = useCookies<string>(['userName'])
const userName = cookie.userName;
Мы ожидаем вас с <b>30.12.2025</b>. Праздник обычно длится до <b>01.01.2025</b>, но если вам будет безумно плохо, то иожно остаться и до второго числа. return (
</p> <>
<CenteredContainer>
<h1>Приглашение на Новый год 2025-2026 🎄</h1>
<p className="mainText">
<h3>
{userName ? <>{userName}</> : <>Дорогая пятка!</>}
! 🦶
</h3>
Приглашаем тебя отпраздновать предстоящий Новый Год <b>2025-2026</b> с нами в сосновой избе, в которой, ко всему прочему, будет праздноваться годовщина нашей жизни в ней!
Наши двери открыты с <b>30.12.2025</b>. Праздник обычно длится до <b>01.01.2025</b>, но если тебе или твоим спутникам будет безумно плохо, то можно остаться и до второго числа.
</p>
<Attendance/>
</CenteredContainer>
</> </>
) )
} }
const localStyles = {
buttonOk: {
margin: '0.5em',
padding: '0.3em',
backgroundColor: 'green'
},
buttonNok: {
margin: '0.5em',
padding: '0.3em',
backgroundColor: 'red'
}
}
export default Greeting; export default Greeting;

View File

@@ -1,84 +1,154 @@
import { useState } from "react";
import useFetchHosting from "../utils/fetchHosting"; import useFetchHosting from "../utils/fetchHosting";
import { useNotification, type NotificationType } from "../NotificationContext";
import { useCookies } from "react-cookie";
import CenteredContainer from "./ChildrenContainer";
import { useState } from "react";
interface ReserveButtonProps { interface ReserveButtonProps {
update: (name: string, id: number) => void, update: (name: string, id: number) => void,
reservedBy: string, unreserve: (token: string, id: number) => void,
id: number, reservedBy: string,
notify: (message: string, type: NotificationType) => void,
id: number,
} }
const ReserveButton: React.FC<ReserveButtonProps> = (props) => { const ReserveButton: React.FC<ReserveButtonProps> = (props) => {
const { reservedBy, update, id } = props; const { reservedBy, update, id, unreserve, notify } = props;
const [name, setName] = useState(reservedBy); const [cookie] = useCookies<string>(['userName'])
const [tokenCookie] = useCookies<string>(['apiToken'])
const userName = cookie.userName;
const isReserved = reservedBy !== ''; const isReserved = reservedBy !== '';
const handleReserve = async () => { const handleReserve = async (name: string) => {
if (name.trim()) { try {
await update(name, id); // Call the update function from props with the name and id await update(name, id);
notify(`Успешно забронировано для ${name}`, 'success');
} catch (error) {
notify(`Не удалось забронировать: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
}
};
const handleUnreserve = async () => {
try {
await unreserve(tokenCookie.apiToken, id);
notify(`Удалось разбронировать ${name}`, 'success');
} catch (error) {
notify(`Не удалось разбронировать: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
}
}
return (
<>
<button onClick={() => handleReserve(userName)} disabled={isReserved}>
{isReserved ? `${reservedBy}` : 'Занять'}
</button>
{(reservedBy == userName) && (
<button onClick={() => handleUnreserve()} disabled={false}></button>
)}
</>
);
};
const Hosting = () => {
const { data, error, loading, update, unreserveHosting, createHosting } = useFetchHosting();
const [name, setName] = useState('');
const [capacity, setCapacity] = useState('');
const [tokenCookie] = useCookies<string>(['apiToken'])
const notify = useNotification();
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); // Prevent the default form submission
if (!name || !capacity) {
notify('Надо заполнить все поля', 'error');
return;
}
try {
await createHosting(tokenCookie.apiToken, name, Number(capacity));
// Reset form fields
setName('');
setCapacity('');
notify('Удалось создать!', 'success');
} catch (error) {
notify(`Не удалось создать: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
} }
}; };
return ( return (
<> <>
<input <CenteredContainer>
type="text" <h2>Поселение</h2>
value={name} <p>
onChange={(e) => setName(e.target.value)} Мы готовы приютить в наших 150 квадратах всех. У нас есть 6 спальных мест. При этом, если
placeholder="Введите ваше имя" вы не хотите тесниться, то рядом с нами есть
disabled={isReserved} // Disable if already reserved <a href="https://www.uoti.net/" target="_blank" rel="noopener noreferrer"> отель</a>, а так же
/> <a href="https://campingsysma.fi/" target="_blank" rel="noopener noreferrer"> кэмпинг-виллы </a>
<button onClick={handleReserve} disabled={isReserved}> (Лучше бронировать заранее если есть надобность. Оба в 1-1,5км от нашего дома).
{isReserved ? 'Занято' : 'Занять'} Спальные места:
</button> </p>
{loading && <div>Loading...</div>}
{error && <div>Error</div>}
{data && (
<div className="table-wrapper scroll-indicator">
<table>
<thead>
<tr>
<th>Размещение</th>
<th>Спальных мест</th>
<th>Бронирование</th>
</tr>
</thead>
<tbody>
{data.furniture && data.furniture.map((item) => (
<tr key={item.id}>
<td>{item.name}</td>
<td>{item.capacity}</td>
<td>
<ReserveButton update={update} reservedBy={item.reservedBy} id={item.id} unreserve={unreserveHosting} notify={notify} />
</td>
</tr>
))}
</tbody>
</table>
<p className="scroll-instruction">Таблицу можно скроллить</p>
</div>
)}
Если вы хотите организовать себе свои спальные места и хотите, чтобы остальные это видели, вы можете добавить свое месо в таблицу.
<form onSubmit={handleSubmit}>
<div>
<label>
Размещение:
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</label>
</div>
<div>
<label>
Спальных мест:
<input
type="number"
value={capacity}
onChange={(e) => setCapacity(e.target.value)}
required
/>
</label>
</div>
<button type="submit">
{'Создать размещение'}
</button>
</form>
</CenteredContainer>
<br />
</> </>
); );
}; };
function Hosting() {
const { data, error, loading, update } = useFetchHosting();
return (
<>
<h2>Поселение</h2>
<p>
Мы готовы вас приютить в наших 150 квадратах. Хоть дом и кажется большим,
но больше 5 гостей уместить будет сложно (но возможно!). При этом, если
вы не хотите тесниться, то рядом с нами есть
<a href="https://www.uoti.net/" target="_blank" rel="noopener noreferrer"> отель</a>,
а так же
<a href="https://campingsysma.fi/" target="_blank" rel="noopener noreferrer"> кэмпинг-виллы </a>
(Лучше бронировать заранее если есть надобность. Оба в 1-1,5км от нашего дома).
<br />
На данный момент мы можем вместить 5 гостей без лишних усилий.
Это подразумевает:
</p>
{loading && <div>Loading...</div>}
{error && <div>Error</div>}
{data && (
<div>
<table>
<thead>
<tr>
<th>Размещение</th>
<th>Вместительность</th>
<th>Бронирование</th>
</tr>
</thead>
<tbody>
{Object.entries(data).map(([id, item]) => (
<tr key={id}>
<td>{item.name}</td>
<td>{item.capacity}</td>
<td>{<ReserveButton update={update} reservedBy={item.reservedBy} id={id}/>}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
);
}
export default Hosting; export default Hosting;

View File

@@ -0,0 +1,156 @@
import { useState, useEffect } from 'react';
import { useCookies } from 'react-cookie';
import { GUESTS } from '../constants/constants';
import useFetchUser from '../utils/fetchUser'; // Import your custom hook
import { useNotification } from '../NotificationContext';
import {Loading} from './Loading';
const InitialSetup = () => {
const [cookie, setCookie] = useCookies();
const [selectedName, setSelectedName] = useState<string | undefined>(undefined);
//const [token] = useState<string | undefined>(cookie.apiToken)
const [isSubmitted, setIsSubmitted] = useState(false);
const [password, setPassword] = useState('');
const [isPasswordSet, setIsPasswordSet] = useState(false); // To track if password is set
const { userSet, passwordCreate, signUser, validToken, isLoading } = useFetchUser(); // Destructure functions from the hook
const notify = useNotification();
const checkUserPassword = async (name: string) => {
const passwordStatus = await userSet(name);
setIsPasswordSet(passwordStatus);
};
const handleSelect = (event: React.ChangeEvent<HTMLSelectElement>) => {
const name = event.target.value;
setCookie('userName', name, { path: '/' });
setSelectedName(name);
checkUserPassword(name);
};
const validateToken = async () => {
const isTokenValid = await validToken(cookie.apiToken);
setIsSubmitted(isTokenValid);
};
useEffect(() => {
if (cookie.apiToken !== undefined) {
validateToken();
}
}, [cookie.apiToken]);
const handlePasswordCreate = async () => {
const message= await passwordCreate(selectedName!, password);
if (message !== '') {
notify(message, 'error')
return
}
};
const handleSignIn = async () => {
const signedIn = await signUser(selectedName!, password); // Implement your sign-in logic here
if (!signedIn) {
notify('Не удалось войти. Может пароль не тот?', 'error')
return
}
validateToken()
};
if (isSubmitted) {
console.log('Selected', selectedName);
return null; // or you can redirect to another component or page
}
if (isLoading) {
return (
<div style={styles.container}>
<Loading/>
</div>
)
}
return (
<div style={styles.container}>
<h2 style={styles.title}>Выбери себя</h2>
<select style={styles.dropdown} onChange={handleSelect} value={selectedName || ''}>
<option value="" disabled>{(cookie.userName == undefined) ? 'Пятка' : cookie.userName}</option>
{GUESTS.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
{(selectedName !== undefined) && (
<>
<h3>{isPasswordSet ? 'Войдите' : 'Создайте свой пароль'}</h3>
<input
type="password"
placeholder="Ввести пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={styles.input}
/>
{isPasswordSet ? (
<>
<button onClick={handleSignIn}>
Войти
</button>
</>
) : (<>
<button
onClick={handlePasswordCreate}
disabled={!password} // Disables the button if no password is entered
>
Создать пароль
</button>
</>)}
</>
)}
</div>
);
};
const styles = {
container: {
position: 'fixed' as 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
backgroundColor: 'rgba(0, 0, 0, 1)',
display: 'flex',
flexDirection: 'column' as 'column',
justifyContent: 'center',
alignItems: 'center',
color: '#fff',
zIndex: 1000,
overflow: 'hidden',
},
title: {
marginBottom: '20px',
},
dropdown: {
padding: '10px',
fontSize: '16px',
cursor: 'pointer',
border: 'none',
borderRadius: '5px',
outline: 'none',
marginBottom: '10px',
},
input: {
padding: '10px',
fontSize: '16px',
border: 'none',
borderRadius: '5px',
outline: 'none',
marginBottom: '10px',
width: '200px', // Set a width
}
}
export default InitialSetup;

View File

@@ -0,0 +1,29 @@
.spinner {
font-size: 100px; /* Adjust size as needed */
animation: spin 2s linear infinite; /* Spin animation */
position: fixed; /* Keep the snowflake in a fixed position */
top: 50%; /* Adjust vertical position */
left: 50%; /* Center horizontally */
transform: translateX(-50%); /* Center the snowflake */
z-index: 10000; /* Ensure it's above the full-screen loading */
}
/* Keyframes for spinning */
@keyframes spin {
0% { transform: translateX(-50%) rotate(0deg); }
100% { transform: translateX(-50%) rotate(360deg); }
}
.full-screen {
display: flex; /* Use flexbox for centering */
align-items: center; /* Center vertically */
justify-content: center; /* Center horizontally */
height: 100vh; /* Full viewport height */
width: 100vw; /* Full viewport width */
background-color: rgba(4, 0, 0); /* Semi-transparent background */
position: fixed; /* Fix it in the viewport */
top: 0;
left: 0;
z-index: 9999; /* Ensure it's above other content */
}

View File

@@ -0,0 +1,34 @@
import React, { useEffect, useState } from 'react';
import './Loading.css'; // Import CSS for styling
export const Loading: React.FC = () => {
return (
<div className="spinner">
🎅🏻
</div>
);
};
export const FullScreenLoading: React.FC = () => {
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Set a timeout to change the loading state
const timer = setTimeout(() => {
setIsLoading(false);
}, 1000); // 1000 ms = 1 second
// Cleanup function to clear the timeout if the component unmounts
return () => clearTimeout(timer);
}, []); // Empty dependency array means it runs once on mount
if (!isLoading) {
return null;
}
return (
<div className="full-screen">
<Loading />
</div>
)
}

View File

@@ -0,0 +1,101 @@
import CenteredContainer from "./ChildrenContainer";
const Program = () => {
return (
<CenteredContainer>
<h2>Программа</h2>
<h3>30 декабря</h3>
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Время</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
<tr>
<td>15-18</td>
<td>Гости приезжают и селятся</td>
</tr>
<tr>
<td>18-19</td>
<td>Ужин</td>
</tr>
<tr>
<td>19-N/A</td>
<td>Отдых и заготовки к кануну Нового Года</td>
</tr>
</tbody>
</table>
</div>
<h3>31 декабря</h3>
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Время</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
<tr>
<td>07-10</td>
<td>Утро, завтрак</td>
</tr>
<tr>
<td>11-14</td>
<td>Сюсьма, прогулки, дополнительные закупки, подготовка к вечеру, обед</td>
</tr>
<tr>
<td>14-19</td>
<td>Готовим ужин, чиллим</td>
</tr>
<tr>
<td>19-23:59</td>
<td>Ужин, отдых, разговоры, игры</td>
</tr>
</tbody>
</table>
</div>
<h3>1 января</h3>
<div className="table-wrapper">
<table>
<thead>
<tr>
<th>Время</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
<tr>
<td>00-N/A</td>
<td>🎄 🍾 🥂 🎇 🎆</td>
</tr>
<tr>
<td>07-12</td>
<td>Утро, завтрак</td>
</tr>
<tr>
<td>12-15</td>
<td>Сауна/Отдых</td>
</tr>
<tr>
<td>15-N/A</td>
<td>Кто-то остается, кто-то собирается домой</td>
</tr>
</tbody>
</table>
</div>
</CenteredContainer>
);
};
export default Program;

View File

@@ -0,0 +1,73 @@
import CenteredContainer from "./ChildrenContainer";
import useFetchUser from "../utils/fetchUser.tsx"
import type { SantaInfo } from "../types/index";
import { useState } from "react";
import { useNotification } from "../NotificationContext.tsx";
function SecretSanta() {
const { updateWishlist, getSantaInfo } = useFetchUser()
const [santaInfo, setSantaInfo] = useState<SantaInfo | null>(null)
const [wishListUrl, setWishListUrl] = useState('')
const notify = useNotification();
const fetchSecretSanta = async () => {
const santaInfoData = await getSantaInfo()
setSantaInfo(santaInfoData)
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const updated = await updateWishlist(wishListUrl)
if (updated) {
notify('Вишлсит обновлен', 'success')
} else {
notify('Не удалось обновить вишлист, все вопросы к админу', 'error')
}
}
return (
<>
<CenteredContainer>
<h2>Secret Santa</h2>
<p className="mainText">
{santaInfo ? (
<>
<p>Вы санта для Пятки: <b>{santaInfo.santa_to}</b></p>
{santaInfo.wishlist ? (
<h4>Пятка оставила вам <a href={santaInfo.wishlist}>вишлист</a></h4>
) : (
<h4>Пятка не оставила вам вишлист, используйте свое воображение. Либо ждите, пока добавит...</h4>
)}
</>
) : (
<button type="submit" onClick={fetchSecretSanta}>Показать чей я Санта!</button>
)}
<hr style={{ color: 'white'}}/>
<b>Добавить свой вишлист</b>
<form onSubmit={handleSubmit}>
<div>
<label>
Ссылка на вишлист:
<input
type="url"
value={wishListUrl}
onChange={(e) => setWishListUrl(e.target.value)}
required
/>
</label>
<button type="submit" style={{ margin: '20px 0.5em' }}>
{'Отправить'}
</button>
</div>
</form>
</p>
</CenteredContainer>
</>
)
}
export default SecretSanta;

View File

@@ -0,0 +1,39 @@
.snowflakes {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none; /* Make snowflakes non-interactive */
overflow: hidden; /* Hide overflow */
z-index: 998; /* Ensure snowflakes are above other content */
}
.snowflake {
position: absolute;
top: -10%; /* Start above the top of the screen */
color: white; /* Snowflake color */
font-size: 1em; /* Size of the snowflake; adjust as needed */
opacity: 0.5; /* Transparency */
animation: fall linear infinite; /* Apply the fall animation */
}
/* Falling animation */
@keyframes fall {
0% {
transform: translateX(0) translateY(0); /* Start position */
}
100% {
transform: translateX(-5vw) translateY(120vh); /* End position */
opacity: 0.1; /* Optional: fade out */
}
}
/* Randomize snowflake size and animation */
.snowflake:nth-child(1) { animation-duration: 6s; left: 10%; font-size: 0.8em;}
.snowflake:nth-child(2) { animation-duration: 8s; left: 20%; font-size: 3em;}
.snowflake:nth-child(3) { animation-duration: 5s; left: 30%; font-size: 4em;}
.snowflake:nth-child(4) { animation-duration: 7s; left: 40%; font-size: 0.9em;}
.snowflake:nth-child(5) { animation-duration: 10s; left: 50%; font-size: 2em;}
/* Add more child selectors for additional snowflakes */

View File

@@ -0,0 +1,26 @@
import React from 'react';
import './Snowflakes.css';
const Snowflakes: React.FC = () => {
// Adjust the number of snowflakes as needed
const snowflakeCount = Array.from({ length: 50 });
return (
<div className="snowflakes">
{snowflakeCount.map((_, index) => (
<div
key={index}
className="snowflake"
style={{
left: `${Math.random() * 100}vw`, // Random position across the full width
animationDuration: `${Math.random() * 20 + 10}s`, // Random fall duration between 2s and 5s
}}
>
</div>
))}
</div>
);
};
export default Snowflakes;

View File

@@ -0,0 +1,19 @@
import CenteredContainer from "./ChildrenContainer";
function Sugegstions() {
return (
<>
<CenteredContainer>
<h2>Ваши предложения и пожелания</h2>
<p className="mainText">
Тут вы можете оставить ваши пожелания/предпочтения для чего-либо. Хотите ли вы купить ракеты, поехат в какой-то из дней куда-то, какое основное блюдо вы бы хотели тд и тп...
<br/><br/>
Таблица в производстве... Ожидайте к <b>середине-концу ноября</b>
</p>
</CenteredContainer>
</>
)
}
export default Sugegstions;

View File

@@ -0,0 +1,13 @@
export const API_URL = 'https://nyipyatki-backend.davydovcloud.com';
export const GUESTS = [
"Медведь",
"Ксения",
"Дед",
"Фил",
"Классик",
"Янка",
"Швед",
"Тюлень",
"Тюлениха",
]

View File

@@ -1,68 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -2,9 +2,15 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { NotificationProvider } from './NotificationContext.tsx'
import { CookiesProvider } from 'react-cookie'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <CookiesProvider>
<NotificationProvider>
<App />
</NotificationProvider>
</CookiesProvider>
</StrictMode>, </StrictMode>,
) )

View File

@@ -1,11 +1,22 @@
// src/types/index.d.ts // src/types/index.d.ts
export interface Furniture { export interface Furniture {
reservedBy: string; id: number; // Unique identifier for the furniture
name: string; name: string; // Name of the furniture
capacity: number; capacity: number; // Capacity of the furniture
reservedBy: string; // Name of the person who reserved the furniture
} }
export interface Hosting { export interface Hosting {
[key: number]: Furniture; furniture: Furniture[]; // Array of furniture items
}
export interface User {
attendance: boolean | null
name: string
}
export interface SantaInfo {
santa_to: string,
wishlist: string
} }

View File

@@ -1,26 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { Hosting } from '../types'; import type { Hosting } from '../types';
import { API_URL } from '../constants/constants';
const mockData: Hosting = {
1: {
reservedBy: "",
name: "Матрац 160см",
capacity: 2
},
2: {
reservedBy: "Vasya",
name: "Кровать 120см",
capacity: 2
},
3: {
reservedBy: "",
name: "Диван",
capacity: 1
},
};
const useFetchHosting = () => { const useFetchHosting = () => {
const [data, setData] = useState(mockData); const [data, setData] = useState<Hosting|null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -28,14 +11,12 @@ const useFetchHosting = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch('https://example.backend.com/hosting'); const response = await fetch(`${API_URL}/hosting`);
if (!response.ok) { if (response.status != 200) {
throw new Error('Network response was not ok'); throw new Error('Network response was not ok');
} }
const result = await response.json(); const result = await response.json();
setData(result); setData(result);
} catch (error) {
setError(error instanceof Error ? error.message : 'Unknown error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -45,7 +26,7 @@ const useFetchHosting = () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await fetch(`https://example.backend.com/hosting/${id}`, { const response = await fetch(`${API_URL}/hosting/${id}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -53,24 +34,71 @@ const useFetchHosting = () => {
body: JSON.stringify({ reservedBy: name }) body: JSON.stringify({ reservedBy: name })
}); });
if (!response.ok) { if (!response.ok) { // Check for non-200 responses
throw new Error('Network response was not ok'); const errorText = await response.text(); // Capture the response text for further insights
throw new Error(`Error ${response.status}: ${errorText}`);
} }
// Optional: Fetch the updated data after reservation // Optional: Fetch the updated data after reservation
await fetchData(); await fetchData();
} catch (error) {
setError(error instanceof Error ? error.message : 'Unknown error');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const unreserveHosting = async (token: string, id: number) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${API_URL}/hosting/${id}/unreserve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token })
});
if (!response.ok) { // Check for non-200 responses
const errorText = await response.text(); // Capture the response text for further insights
throw new Error(`Error ${response.status}: ${errorText}`);
}
// Optional: Fetch the updated data after reservation
await fetchData();
} finally {
setLoading(false);
}
};
const createHosting = async (token: string, name: string, capacity: number) => {
setError(null);
try {
const response = await fetch(`${API_URL}/hosting/create`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token, name, capacity })
});
if (!response.ok) { // Check for non-200 responses
const errorText = await response.text(); // Capture the response text for further insights
console.log(`Error ${response.status}: ${errorText}`)
throw new Error(`Error ${response.status}: ${errorText}`);
}
// Optional: Fetch the updated data after reservation
await fetchData();
} finally {
}
};
useEffect(() => { useEffect(() => {
//fetchData(); // Initial fetch on mount fetchData(); // Initial fetch on mount
}, []); }, []);
return { data, error, loading, refetch: fetchData, update: updateData }; return { data, error, loading, refetch: fetchData, update: updateData, unreserveHosting, createHosting};
}; };
export default useFetchHosting; export default useFetchHosting;

View File

@@ -0,0 +1,240 @@
import { useCookies } from 'react-cookie';
import { API_URL } from '../constants/constants';
import { hashPassword } from './hashPassword';
import { useState } from 'react';
import type { User, SantaInfo } from '../types';
const useFetchUser = () => {
const [isLoading, setIsLoading] = useState(false)
const [apiCookie, setApiCookie] = useCookies(['apiToken']);
const [, setUserNameCookie] = useCookies(['userName'])
const userSet = async (userName: string): Promise<boolean> => {
try {
setIsLoading(true)
const response = await fetch(`${API_URL}/users/isSet?userName=${encodeURIComponent(userName)}`);
setIsLoading(false)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
return data; // Assuming the server returns true/false
} catch (error) {
console.error('Error checking user password status:', error);
return false;
}
};
const passwordCreate = async (userName: string, password: string): Promise<string> => {
// Simple validation: password should not be empty and should have a minimum length
if (!password || password.length < 6) {
console.error('Password should be at least 6 characters long.');
return 'Пароль должен иметь, как минимум 6 символов';
}
try {
const hashedPassword = hashPassword(password)
const response = await fetch(`${API_URL}/users/createPassword`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userName,
password: hashedPassword,
}),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
if (data.success) {
setApiCookie('apiToken', data.token, { path: '/' });
console.log(`Password created for ${userName}`);
return ''; // Password creation success
}
return 'Не удалось создать пароль'; // Password creation failure
} catch (error) {
console.error('Error creating password:', error);
return 'Пароль не создан:' + error;
}
};
const signUser = async (userName: string, password: string): Promise<boolean> => {
try {
const hashedPassword = hashPassword(password); // Implement this function to hash the password
const response = await fetch(`${API_URL}/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
userName,
password: hashedPassword,
}),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
if (data.token) {
setApiCookie('apiToken', data.token, { path: '/' });
console.log(`User ${userName} signed in.`);
return true; // Sign-in success
}
return false; // Sign-in failed
} catch (error) {
console.error('Error logging in:', error);
return false;
}
};
const validToken = async (token: string | undefined): Promise<boolean> => {
try {
setIsLoading(true)
const response = await fetch(`${API_URL}/login/validateToken`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token
}),
});
setIsLoading(false)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
if (!data.userName) throw new Error(`Could not retrieve userName from token`);
setUserNameCookie('userName', data.userName, { path: '/' });
return data.tokenValid
} catch (error) {
console.error('Error validating token:', error);
return false;
}
}
const getAttendance = async (): Promise<boolean | null> => {
const token = apiCookie.apiToken
try {
const response = await fetch(`${API_URL}/users/attendance?token=${encodeURIComponent(token)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
if (!data.success) throw new Error(data.message);
return data.attendance
} catch (error) {
console.error('Error retrieving attendance:', error);
return null
}
}
const updateWishlist = async (wishlistUrl: string): Promise<boolean> => {
const token = apiCookie.apiToken
try {
const response = await fetch(`${API_URL}/users/wishlist`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
wishlist: wishlistUrl,
}),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
if (!data.success) throw new Error(data.message);
return true; // Attendance updated successfully
} catch (error) {
console.error('Error updating wishlist:', error);
return false; // Attendance update failed
}
}
const getSantaInfo = async (): Promise<SantaInfo | null> => {
const token = apiCookie.apiToken
try {
const response = await fetch(`${API_URL}/users/santa?token=${encodeURIComponent(token)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
if (!data.success) throw new Error(data.message);
return data.santa_info
} catch (error) {
console.error('Error retrieving attendance:', error);
return null
}
}
const updateAttendance = async (attendanceStatus: boolean): Promise<boolean> => {
const token = apiCookie.apiToken
try {
const response = await fetch(`${API_URL}/users/attendance`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token,
attendance: attendanceStatus,
}),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
if (!data.success) throw new Error(data.message);
return true; // Attendance updated successfully
} catch (error) {
console.error('Error updating attendance:', error);
return false; // Attendance update failed
}
}
const getAttendanceAll = async (): Promise<User[]> => {
const token = apiCookie.apiToken
try {
const response = await fetch(`${API_URL}/users/attendance/all?token=${encodeURIComponent(token)}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const data = await response.json();
if (!data.success) throw new Error(data.message);
return data.attendance_list
} catch (error) {
console.error('Error retrieving attendance:', error);
return []
}
}
return { userSet, passwordCreate, signUser, validToken, updateAttendance, updateWishlist, getAttendance, isLoading, getAttendanceAll, getSantaInfo };
};
export default useFetchUser;

View File

@@ -0,0 +1,10 @@
// src/utils/passwordHasher.ts
import SHA256 from 'crypto-js/sha256';
/**
* Hashes a password using SHA-256.
* @param {string} password - The plain text password.
* @returns {string} - The hashed password in hex format.
*/
export const hashPassword = (password: string): string => {
return SHA256(password).toString(); // Returns the hash in hex format
};

View File

@@ -4,4 +4,7 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
allowedHosts: ['nyipyatki-dev.davydovcloud.com'],
}
}) })