Compare commits
27 Commits
1980f61242
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c4b68400b3 | |||
| d7df194a12 | |||
| 35fee0f473 | |||
| e484ded5be | |||
| 2a280dcba0 | |||
| 6337ae432d | |||
| 826cbf18b1 | |||
| 167c83f7d4 | |||
| 078e61e993 | |||
| bdee3b3efa | |||
| 37b08927a7 | |||
| 46f2d69800 | |||
| 24790d8e69 | |||
| 07205396c8 | |||
| 433c6cf2f8 | |||
| 37435eeb66 | |||
| a0e3385670 | |||
| 8e72e75a15 | |||
| 1277d36a42 | |||
| a9d06af3ae | |||
| bf441a6891 | |||
| 8323a9c1d8 | |||
| 6bf624a67f | |||
| bf3dc67754 | |||
| 1c7ddfc8a9 | |||
| cd411f8066 | |||
| 46a18fae99 |
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,16 +1,3 @@
|
||||
# ignore sqlite db
|
||||
*.db
|
||||
|
||||
# ignore my debug setting
|
||||
*.cfg
|
||||
|
||||
# ignore py cache
|
||||
src/__pycache__
|
||||
|
||||
# ignore any image first
|
||||
*.png
|
||||
*.jpg
|
||||
*.gif
|
||||
|
||||
# elimate vscode
|
||||
.vscode
|
||||
## ======== Personal ========
|
||||
# Ignore VSCode
|
||||
.vscode/
|
||||
|
||||
21
README.md
21
README.md
@@ -1,3 +1,24 @@
|
||||
# coconut-leaf
|
||||
|
||||
A self-host, multi-account calendar system.
|
||||
|
||||
## Warning
|
||||
|
||||
This project still work in progress. Because this project need a massive refactor now.
|
||||
If you want to check out the first version which can fufill basic usage, please switch to `v1-maintain` branch. In `main` branch, I am refactoring v1 and it will be updated to v2 in future.
|
||||
The first version of this project have too much C-style JavaScript. It is too complicated to maintain and cannot add any other new features. Therefore, it needs to be fully refactored using ES6 and some modern JavaScript tools. It will come soon.
|
||||
|
||||
## Features & shortcomings
|
||||
|
||||
### Features
|
||||
|
||||
* Basic calendar(valid range from 1970 to 2200)
|
||||
* Simple event system(including summary, color and etc)
|
||||
* Simple account system and share system
|
||||
* An looping event system.
|
||||
|
||||
### Shortcomings
|
||||
|
||||
* No extra properties for event(including location, busy status and etc. All of them can be written in summary property and extirely useless for myself. There are no plan to implement these in future.)
|
||||
* No alarm system(should be implemented in frontend in future?)
|
||||
|
||||
|
||||
10
ROADMAP.md
Normal file
10
ROADMAP.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Roadmap
|
||||
|
||||
1. 前后端分离,将前端静态文件和后端Python分装到两个文件夹中。同时辅助类文件夹改换位置。
|
||||
1. 后端使用Astral UV重构,使得项目可以跑起来。
|
||||
1. 定为1.1版本。1.0版本也要打tag然后提交。然后分叉v1-maintain分支。后续在master上开发v2。
|
||||
1. 后端数据库字段重命名。
|
||||
1. 前后端通信API命名格式修改。
|
||||
1. 使用Vue重写前端
|
||||
1. 使用Tailwind重写前端CSS
|
||||
1. 使用Go重写后端。
|
||||
61
assets/.nginx.conf
Normal file
61
assets/.nginx.conf
Normal file
@@ -0,0 +1,61 @@
|
||||
# ============================
|
||||
# 路由 1: /web -> 静态文件
|
||||
# ============================
|
||||
location /web {
|
||||
# 使用 alias 精确映射
|
||||
# 请求 /web/index.html -> /var/www/static/index.html
|
||||
alias /var/www/static;
|
||||
|
||||
# 静态文件优化
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800";
|
||||
|
||||
# 尝试返回文件,不存在则返回404(避免落入其他location)
|
||||
try_files $uri $uri/ =404;
|
||||
|
||||
# 可选:启用 gzip 压缩
|
||||
gzip_static on;
|
||||
}
|
||||
|
||||
# ============================
|
||||
# 路由 2: /api -> Go 程序 (8848端口)
|
||||
# ============================
|
||||
location /api {
|
||||
# 反向代理到本地 Go 服务
|
||||
proxy_pass http://127.0.0.1:8848;
|
||||
|
||||
# 重要:保留原始请求头
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 支持(如果 Go 程序需要)
|
||||
# proxy_http_version 1.1;
|
||||
# proxy_set_header Upgrade $http_upgrade;
|
||||
# proxy_set_header Connection "upgrade";
|
||||
|
||||
# 超时设置(根据业务调整)
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
|
||||
# 缓冲设置(可选,大文件上传时注意调整)
|
||||
proxy_buffering on;
|
||||
proxy_buffer_size 4k;
|
||||
proxy_buffers 8 4k;
|
||||
}
|
||||
|
||||
# ============================
|
||||
# 可选:根路径处理
|
||||
# ============================
|
||||
location = / {
|
||||
# 重定向到 /web
|
||||
return 302 /web/;
|
||||
}
|
||||
|
||||
# 禁止访问隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
101
assets/ics2csv.py
Normal file
101
assets/ics2csv.py
Normal file
@@ -0,0 +1,101 @@
|
||||
import icalendar
|
||||
|
||||
def DumpComponentHeader(file, component):
|
||||
fields = []
|
||||
inclusiveUnitCounter = 0
|
||||
fields += map(lambda x: x + ' - required', component.required)
|
||||
fields += map(lambda x: x + ' - singletons', component.singletons)
|
||||
for units in component.inclusive:
|
||||
fields += map(lambda x: x + ' - inclusive{}'.format(inclusiveUnitCounter), units)
|
||||
inclusiveUnitCounter += 1
|
||||
fields += ('exclusive - name', 'exclusive - data')
|
||||
fields += ('multiple', )
|
||||
file.write(','.join(fields))
|
||||
file.write('\n')
|
||||
|
||||
def DumpComponentData(file, component):
|
||||
data = []
|
||||
gotten_instance = None
|
||||
|
||||
for item in component.required:
|
||||
gotten_instance = component.get(item)
|
||||
if gotten_instance is not None:
|
||||
data.append(AdvancedFormater(gotten_instance))
|
||||
else:
|
||||
data.append('')
|
||||
for item in component.singletons:
|
||||
gotten_instance = component.get(item)
|
||||
if gotten_instance is not None:
|
||||
data.append(AdvancedFormater(gotten_instance))
|
||||
else:
|
||||
data.append('')
|
||||
for units in component.inclusive:
|
||||
for item in units:
|
||||
gotten_instance = component.get(item)
|
||||
if gotten_instance is not None:
|
||||
data.append(AdvancedFormater(gotten_instance))
|
||||
else:
|
||||
data.append('')
|
||||
|
||||
gotten_name = ""
|
||||
gotten_data = ""
|
||||
for item in component.exclusive:
|
||||
gotten_instance = component.get(item)
|
||||
if gotten_instance is not None:
|
||||
gotten_name = item
|
||||
gotten_data = AdvancedFormater(gotten_instance)
|
||||
break
|
||||
data.append(gotten_name)
|
||||
data.append(gotten_data)
|
||||
|
||||
for item in component.multiple:
|
||||
gotten_instance = component.get(item)
|
||||
if gotten_instance is not None:
|
||||
data.append('- {} -'.format(item))
|
||||
data.append(AdvancedFormater(gotten_instance))
|
||||
else:
|
||||
data.append('')
|
||||
|
||||
file.write(','.join(data))
|
||||
file.write('\n')
|
||||
|
||||
def AdvancedFormater(data):
|
||||
if isinstance(data, icalendar.prop.vDDDTypes):
|
||||
return str(data.dt)
|
||||
else:
|
||||
return str(data)
|
||||
|
||||
# read file
|
||||
icsFile = open('test.ics', 'rb')
|
||||
cal = icalendar.Calendar.from_ical(icsFile.read())
|
||||
icsFile.close()
|
||||
|
||||
# analyse file
|
||||
csvEvent = open('event.csv', 'w')
|
||||
csvEventHeader = False
|
||||
csvAlarm = open('alarm.csv', 'w')
|
||||
csvAlarmHeader = False
|
||||
|
||||
eventCount = 0
|
||||
alarmCount = 0
|
||||
miscCount = 0
|
||||
for component in cal.walk():
|
||||
if component.name == 'VEVENT':
|
||||
eventCount += 1
|
||||
if not csvEventHeader:
|
||||
DumpComponentHeader(csvEvent, component)
|
||||
csvEventHeader = True
|
||||
DumpComponentData(csvEvent, component)
|
||||
elif component.name == 'VALARM':
|
||||
alarmCount += 1
|
||||
if not csvAlarmHeader:
|
||||
DumpComponentHeader(csvAlarm, component)
|
||||
csvAlarmHeader = True
|
||||
DumpComponentData(csvAlarm, component)
|
||||
else:
|
||||
miscCount += 1
|
||||
|
||||
|
||||
csvEvent.close()
|
||||
csvAlarm.close()
|
||||
print('Event count: {}\nAlarm count: {}\nMisc count: {}'.format(eventCount, alarmCount, miscCount))
|
||||
156
assets/ics_converter.py
Normal file
156
assets/ics_converter.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import icalendar
|
||||
import sys
|
||||
import os
|
||||
import database
|
||||
import json
|
||||
import datetime
|
||||
import dt as localdt
|
||||
|
||||
def AdvancedDatetTimeGet(dt, isStartDateTime):
|
||||
if isinstance(dt, datetime.datetime):
|
||||
gottenDatetime = int(dt.timestamp() / 60)
|
||||
elif isinstance(dt, datetime.date):
|
||||
gottenDatetime = int(datetime.datetime(
|
||||
dt.year,
|
||||
dt.month,
|
||||
dt.day,
|
||||
0 if isStartDateTime else 23,
|
||||
0 if isStartDateTime else 59,
|
||||
0 if isStartDateTime else 59,
|
||||
0, tzinfo=LOCAL_TZ
|
||||
).timestamp() / 60)
|
||||
else:
|
||||
raise Exception('Unexpected data')
|
||||
|
||||
timezoneOffset = LOCAL_UTC_OFFSET
|
||||
return (gottenDatetime, timezoneOffset)
|
||||
|
||||
def AdvancedDateTimeAnalyser(component):
|
||||
startDatetimeRef = component.get('DTSTART').dt
|
||||
(startDatetime, timezoneOffset) = AdvancedDatetTimeGet(startDatetimeRef, True)
|
||||
|
||||
if component.get('DTEND') is not None:
|
||||
(endDatetime, _) = AdvancedDatetTimeGet(startDatetimeRef, False)
|
||||
elif component.get('DURATION') is not None:
|
||||
endDurationRef = component.get('DURATION').dt
|
||||
if isinstance(endDurationRef, datetime.timedelta):
|
||||
endDatetime = startDatetime + int(endDurationRef.total_seconds() / 60)
|
||||
else:
|
||||
raise Exception('Unexpected data')
|
||||
else:
|
||||
raise Exception('Unexpected data')
|
||||
|
||||
return (startDatetime, endDatetime, timezoneOffset)
|
||||
|
||||
def LoopRulesConverter(component):
|
||||
jsonData = component.get('RRULE')
|
||||
if jsonData is None:
|
||||
return ""
|
||||
|
||||
loopRules = ""
|
||||
loopStopRules = ""
|
||||
freq = jsonData.get('FREQ')[0]
|
||||
if freq == 'MONTHLY':
|
||||
loopRules = 'MSA{}'.format(str(jsonData.get('INTERVAL')[0]))
|
||||
elif freq == 'WEEKLY':
|
||||
occupiedWeek = [False, ] * 7
|
||||
for item in jsonData.get('BYDAY'):
|
||||
occupiedWeek[WEEK_DICT[item]] = True
|
||||
loopRules = 'W{}{}'.format(
|
||||
''.join(map(lambda x: 'T' if x else 'F', occupiedWeek)),
|
||||
str(jsonData.get('INTERVAL')[0])
|
||||
)
|
||||
elif freq == 'YEARLY':
|
||||
loopRules = 'YS{}'.format(str(jsonData.get('INTERVAL')[0]))
|
||||
else:
|
||||
raise Exception('Unexpected data')
|
||||
|
||||
if jsonData.get('COUNT') is not None:
|
||||
loopStopRules = 'T{}'.format(str(jsonData.get('COUNT')[0]))
|
||||
else:
|
||||
loopStopRules = 'F'
|
||||
|
||||
return loopRules + '-' + loopStopRules
|
||||
|
||||
# ============================ read args
|
||||
icsFilePath = sys.argv[1]
|
||||
if not os.path.isfile(icsFilePath):
|
||||
print('Fail to load ics file')
|
||||
sys.exit(1)
|
||||
|
||||
# read file
|
||||
icsFile = open(icsFilePath, 'rb')
|
||||
cal = icalendar.Calendar.from_ical(icsFile.read())
|
||||
icsFile.close()
|
||||
|
||||
# ============================ init const
|
||||
utfOffset = float(input('Input this ics file\'s utc offset (time unit: hour)>'))
|
||||
LOCAL_UTC_OFFSET = int(utfOffset * 60)
|
||||
LOCAL_TZ = localdt.UTCTimezone(LOCAL_UTC_OFFSET)
|
||||
WEEK_DICT = {
|
||||
"SU": 6, "MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5,
|
||||
}
|
||||
|
||||
# ============================ pick database
|
||||
|
||||
db = database.CalendarDatabase()
|
||||
db.open()
|
||||
username = input('Input username >')
|
||||
password = input('Input password >')
|
||||
(status, error, token) = db.common_webLogin(username, password, 'Python backend', '127.0.0.1')
|
||||
if not status:
|
||||
print('Fail to login.')
|
||||
sys.exit(1)
|
||||
(status, error, collectionList) = db.collection_getFullOwn(token)
|
||||
if not status:
|
||||
print('Database return an error')
|
||||
sys.exit(1)
|
||||
print('Pick a collection to insert imported events')
|
||||
counter = 0
|
||||
for i in collectionList:
|
||||
print('{}\t{}'.format(counter, i[1]))
|
||||
counter += 1
|
||||
pickedIndex = int(input())
|
||||
collectionUuid = collectionList[pickedIndex][0]
|
||||
|
||||
# ============================ analyse file
|
||||
eventCount = 0
|
||||
allCount = 0
|
||||
for component in cal.walk():
|
||||
allCount += 1
|
||||
# only import event chunk
|
||||
if component.name == 'VEVENT':
|
||||
eventCount += 1
|
||||
title = str(component.get('SUMMARY'))
|
||||
descriptionPrototype = {
|
||||
'color': '#1e90ff',
|
||||
'description': None
|
||||
}
|
||||
descriptionList = []
|
||||
if component.get('DESCRIPTION') is not None and str(component.get('DESCRIPTION')) != '':
|
||||
descriptionList.append(component.get('DESCRIPTION'))
|
||||
if component.get('LOCATION') is not None and str(component.get('LOCATION')) != '':
|
||||
descriptionList.append(component.get('LOCATION'))
|
||||
descriptionPrototype['description'] = '\n'.join(descriptionList)
|
||||
description = json.dumps(descriptionPrototype)
|
||||
|
||||
(eventDateTimeStart, eventDateTimeEnd, timezoneOffset) = AdvancedDateTimeAnalyser(component)
|
||||
loopRules = LoopRulesConverter(component)
|
||||
|
||||
(status, _, _) = db.calendar_add(
|
||||
token,
|
||||
collectionUuid,
|
||||
title,
|
||||
description,
|
||||
eventDateTimeStart,
|
||||
eventDateTimeEnd,
|
||||
loopRules,
|
||||
timezoneOffset
|
||||
)
|
||||
if not status:
|
||||
print('Database return an error')
|
||||
sys.exit(1)
|
||||
|
||||
db.common_logout(token)
|
||||
db.close()
|
||||
print('All chunk: {}\nEvent count: {}'.format(allCount, eventCount))
|
||||
3
assets/migration/README.md
Normal file
3
assets/migration/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Migration
|
||||
|
||||
This directory contains the migration scripts for the database.
|
||||
49
assets/migration/v1_to_v2.sqlite.sql
Normal file
49
assets/migration/v1_to_v2.sqlite.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- Migration script for coconut-leaf database v1 to v2
|
||||
-- This script updates field names by:
|
||||
-- 1. Removing 'ccn_' prefix from all fields
|
||||
-- 2. Converting camelCase to snake_case
|
||||
|
||||
-- Step 1: Rename user table columns
|
||||
ALTER TABLE user RENAME COLUMN ccn_name TO name;
|
||||
ALTER TABLE user RENAME COLUMN ccn_password TO password;
|
||||
ALTER TABLE user RENAME COLUMN ccn_isAdmin TO is_admin;
|
||||
ALTER TABLE user RENAME COLUMN ccn_salt TO salt;
|
||||
|
||||
-- Step 2: Rename token table columns
|
||||
ALTER TABLE token RENAME COLUMN ccn_user TO user;
|
||||
ALTER TABLE token RENAME COLUMN ccn_token TO token;
|
||||
ALTER TABLE token RENAME COLUMN ccn_tokenExpireOn TO token_expire_on;
|
||||
ALTER TABLE token RENAME COLUMN ccn_ua TO ua;
|
||||
ALTER TABLE token RENAME COLUMN ccn_ip TO ip;
|
||||
|
||||
-- Step 3: Rename collection table columns
|
||||
ALTER TABLE collection RENAME COLUMN ccn_uuid TO uuid;
|
||||
ALTER TABLE collection RENAME COLUMN ccn_name TO name;
|
||||
ALTER TABLE collection RENAME COLUMN ccn_user TO user;
|
||||
ALTER TABLE collection RENAME COLUMN ccn_lastChange TO last_change;
|
||||
|
||||
-- Step 4: Rename share table columns
|
||||
ALTER TABLE share RENAME COLUMN ccn_uuid TO uuid;
|
||||
ALTER TABLE share RENAME COLUMN ccn_target TO target;
|
||||
|
||||
-- Step 5: Rename calendar table columns
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_uuid TO uuid;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_belongTo TO belong_to;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_title TO title;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_description TO description;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_lastChange TO last_change;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_eventDateTimeStart TO event_date_time_start;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_eventDateTimeEnd TO event_date_time_end;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_timezoneOffset TO timezone_offset;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_loopRules TO loop_rules;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_loopDateTimeStart TO loop_date_time_start;
|
||||
ALTER TABLE calendar RENAME COLUMN ccn_loopDateTimeEnd TO loop_date_time_end;
|
||||
|
||||
-- Step 6: Rename todo table columns
|
||||
ALTER TABLE todo RENAME COLUMN ccn_uuid TO uuid;
|
||||
ALTER TABLE todo RENAME COLUMN ccn_belongTo TO belong_to;
|
||||
ALTER TABLE todo RENAME COLUMN ccn_data TO data;
|
||||
ALTER TABLE todo RENAME COLUMN ccn_lastChange TO last_change;
|
||||
|
||||
-- Note: Foreign key constraints will be automatically updated by SQLite when renaming columns
|
||||
-- No additional steps needed for foreign keys
|
||||
19
backend/.gitignore
vendored
Normal file
19
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
## ======== Personal ========
|
||||
# Database file
|
||||
*.db
|
||||
|
||||
# Ignore setting file
|
||||
coconut-leaf.toml
|
||||
|
||||
## ======== Python ========
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
84
backend/coconut-leaf.py
Normal file
84
backend/coconut-leaf.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import sys
|
||||
from argparse import ArgumentParser
|
||||
from typing import cast
|
||||
from pathlib import Path
|
||||
|
||||
import server
|
||||
import config
|
||||
import utils
|
||||
import database
|
||||
import logger
|
||||
from logger import LOGGER, LoggerLevel
|
||||
|
||||
|
||||
def GetUsernamePassword() -> tuple[str, str]:
|
||||
print("What is the first username of this calendar system?")
|
||||
cache = input()
|
||||
while not utils.IsValidUsername(cache):
|
||||
print("Sorry, invalid data. Please try again.")
|
||||
cache = input()
|
||||
username = cache
|
||||
|
||||
print("Input this user password:")
|
||||
cache = input()
|
||||
while not utils.IsValidPassword(cache):
|
||||
print("Sorry, invalid data. Please try again.")
|
||||
cache = input()
|
||||
password = cache
|
||||
|
||||
return (username, password)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Set as INFO level in default first,
|
||||
# and we will change it once we load the configuration file.
|
||||
logger.set_level(LoggerLevel.INFO)
|
||||
|
||||
# Receive arguments
|
||||
parser = ArgumentParser(
|
||||
description="The server of light, self-host and multi-account calendar system."
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--config",
|
||||
required=True,
|
||||
type=Path,
|
||||
action="store",
|
||||
metavar="CONFIG_TOML",
|
||||
dest="config",
|
||||
help="The configuration file for coconut-leaf",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--init",
|
||||
action="store_true",
|
||||
dest="init",
|
||||
help="Set for initialize the calendar system",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Show splash
|
||||
LOGGER.info("Coconut-leaf")
|
||||
LOGGER.info("A light, self-host and multi-account calendar system")
|
||||
LOGGER.info("Project: https://github.com/yyc12345/coconut-leaf")
|
||||
LOGGER.info("===================")
|
||||
|
||||
# Load config file
|
||||
try:
|
||||
config.setup_config(cast(Path, args.config))
|
||||
except Exception as e:
|
||||
LOGGER.critical(f"Error loading config file: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Change logging level again according to whether enable debug mode
|
||||
logging_level = LoggerLevel.DEBUG if config.get_config().others.debug else LoggerLevel.INFO
|
||||
logger.set_level(logging_level)
|
||||
|
||||
# Initialize the calendar system if needed
|
||||
if cast(bool, args.init):
|
||||
gotten_data = GetUsernamePassword()
|
||||
calendar = database.CalendarDatabase()
|
||||
calendar.init(*gotten_data)
|
||||
calendar.close()
|
||||
|
||||
LOGGER.info("Staring server...")
|
||||
server.run()
|
||||
22
backend/coconut-leaf.template.toml
Normal file
22
backend/coconut-leaf.template.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[database]
|
||||
driver = "sqlite"
|
||||
|
||||
[database.config]
|
||||
path = "coconut-leaf.db"
|
||||
|
||||
# [database]
|
||||
# driver = "mysql"
|
||||
#
|
||||
# [database.config]
|
||||
# host = "localhost"
|
||||
# port = 3306
|
||||
# user = "root"
|
||||
# password = "password"
|
||||
# database = "coconut_leaf"
|
||||
|
||||
[web]
|
||||
port = 8848
|
||||
|
||||
[others]
|
||||
auto-token-clean-duration = 86400
|
||||
debug = true
|
||||
130
backend/config.py
Normal file
130
backend/config.py
Normal file
@@ -0,0 +1,130 @@
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DatabaseDriver(StrEnum):
|
||||
SQLITE = "sqlite"
|
||||
MYSQL = "mysql"
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return DatabaseDriver(raw["driver"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SqliteDatabaseConfig:
|
||||
path: str
|
||||
"""Database path"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return SqliteDatabaseConfig(raw["path"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MysqlDatabaseConfig:
|
||||
host: str
|
||||
"""Database host"""
|
||||
port: int
|
||||
"""Database port"""
|
||||
user: str
|
||||
"""Database user"""
|
||||
password: str
|
||||
"""Database password"""
|
||||
database: str
|
||||
"""Database name"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return MysqlDatabaseConfig(
|
||||
raw["host"], raw["port"], raw["user"], raw["password"], raw["database"]
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DatabaseConfig:
|
||||
driver: DatabaseDriver
|
||||
"""Database driver"""
|
||||
config: SqliteDatabaseConfig | MysqlDatabaseConfig
|
||||
"""Database config"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
if raw["driver"] == DatabaseDriver.SQLITE:
|
||||
return DatabaseConfig(
|
||||
DatabaseDriver.SQLITE, SqliteDatabaseConfig.from_raw(raw["config"])
|
||||
)
|
||||
elif raw["driver"] == DatabaseDriver.MYSQL:
|
||||
return DatabaseConfig(
|
||||
DatabaseDriver.MYSQL, MysqlDatabaseConfig.from_raw(raw["config"])
|
||||
)
|
||||
else:
|
||||
raise ValueError("Invalid database driver")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WebConfig:
|
||||
port: int
|
||||
"""Web server port"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return WebConfig(raw["port"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OthersConfig:
|
||||
debug: bool
|
||||
"""Whether enable debug mode"""
|
||||
auto_token_clean_duration: int
|
||||
"""Auto token clean duration"""
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return OthersConfig(raw["debug"], raw["auto-token-clean-duration"])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
database: DatabaseConfig
|
||||
web: WebConfig
|
||||
others: OthersConfig
|
||||
|
||||
@staticmethod
|
||||
def from_raw(raw: dict):
|
||||
return Config(
|
||||
database=DatabaseConfig.from_raw(raw["database"]),
|
||||
web=WebConfig.from_raw(raw["web"]),
|
||||
others=OthersConfig.from_raw(raw["others"]),
|
||||
)
|
||||
|
||||
|
||||
_CONFIG: Optional[Config] = None
|
||||
|
||||
|
||||
def setup_config(p: Path) -> None:
|
||||
"""
|
||||
Setup config by given path.
|
||||
|
||||
Raise exception if config file is invalid.
|
||||
"""
|
||||
with open(p, "rb") as f:
|
||||
raw = tomllib.load(f)
|
||||
|
||||
global _CONFIG
|
||||
_CONFIG = Config.from_raw(raw)
|
||||
|
||||
|
||||
def get_config() -> Config:
|
||||
"""
|
||||
Get config instance.
|
||||
|
||||
Raises RuntimeError if config is not loaded.
|
||||
"""
|
||||
if _CONFIG is None:
|
||||
raise RuntimeError("Config is not loaded. Call setup_config() first.")
|
||||
else:
|
||||
return _CONFIG
|
||||
708
backend/database.py
Normal file
708
backend/database.py
Normal file
@@ -0,0 +1,708 @@
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import cast
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, ParamSpec, TypeVar, Generic
|
||||
|
||||
import dt
|
||||
import utils
|
||||
import config
|
||||
from logger import LOGGER
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
P = ParamSpec('P')
|
||||
R = TypeVar('R')
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResponseBody(Generic[T]):
|
||||
"""The generic response body for API return."""
|
||||
|
||||
success: bool
|
||||
"""True if this operation is successful, otherwise false."""
|
||||
error: str
|
||||
"""The error message provided when operation failed."""
|
||||
data: T | None
|
||||
"""The payload provided when operation successed."""
|
||||
|
||||
|
||||
class DbException(Exception):
|
||||
"""Error occurs when manipulating with database."""
|
||||
pass
|
||||
|
||||
|
||||
def SafeDatabaseOperation(inner: Callable[P, R]) -> Callable[P, ResponseBody[R]]:
|
||||
def wrapper(*args, **kwargs) -> ResponseBody[R]:
|
||||
# extract self from args
|
||||
self: 'CalendarDatabase' = args[0]
|
||||
# get config
|
||||
cfg = config.get_config()
|
||||
|
||||
with self.mutex:
|
||||
# try to fetching database and allocate database cursor
|
||||
try:
|
||||
db = self._get_db()
|
||||
self._allocate_cursor()
|
||||
except Exception as e:
|
||||
self._free_cursor()
|
||||
if cfg.others.debug:
|
||||
LOGGER.exception(e)
|
||||
return ResponseBody(False, str(e), None)
|
||||
|
||||
# do real data work
|
||||
try:
|
||||
currentTime = utils.GetCurrentTimestamp()
|
||||
if currentTime - self.latestClean > cfg.others.auto_token_clean_duration:
|
||||
self.latestClean = currentTime
|
||||
LOGGER.info('Cleaning outdated token...')
|
||||
self.tokenOper_clean()
|
||||
|
||||
result = ResponseBody(True, '', inner(*args, **kwargs))
|
||||
self._free_cursor()
|
||||
db.commit()
|
||||
return result
|
||||
except Exception as e:
|
||||
self._free_cursor()
|
||||
db.rollback()
|
||||
if cfg.others.debug:
|
||||
LOGGER.exception(e)
|
||||
return ResponseBody(False, str(e), None)
|
||||
|
||||
return wrapper
|
||||
|
||||
class CalendarDatabase:
|
||||
|
||||
db: sqlite3.Connection | None
|
||||
cursor: sqlite3.Cursor | None
|
||||
mutex: threading.Lock
|
||||
latestClean: int
|
||||
|
||||
def __init__(self):
|
||||
self.db = None
|
||||
self.cursor = None
|
||||
self.mutex = threading.Lock()
|
||||
self.latestClean = 0
|
||||
|
||||
def open(self):
|
||||
if (self.db is not None):
|
||||
raise DbException('Database is already opened')
|
||||
|
||||
cfg = config.get_config()
|
||||
match cfg.database.driver:
|
||||
case config.DatabaseDriver.SQLITE:
|
||||
self.db = sqlite3.connect(cast(config.SqliteDatabaseConfig, cfg.database.config).path, check_same_thread = False)
|
||||
self.db.execute('PRAGMA encoding = "UTF-8";')
|
||||
self.db.execute('PRAGMA foreign_keys = ON;')
|
||||
case config.DatabaseDriver.MYSQL:
|
||||
raise DbException('Not implemented database')
|
||||
case _:
|
||||
raise DbException('Unknow database type')
|
||||
|
||||
def init(self, username: str, password: str):
|
||||
if (self.db is not None):
|
||||
raise DbException('Database is already opened')
|
||||
|
||||
# establish tables
|
||||
cfg = config.get_config()
|
||||
backend_path = Path(__file__).resolve().parent
|
||||
backend_sql_path = backend_path / 'sql'
|
||||
match cfg.database.driver:
|
||||
case config.DatabaseDriver.SQLITE:
|
||||
sql_file = backend_sql_path / 'sqlite.sql'
|
||||
case config.DatabaseDriver.MYSQL:
|
||||
raise DbException('Not implemented database')
|
||||
case _:
|
||||
raise DbException('Unknow database type')
|
||||
|
||||
self.open()
|
||||
db = self._get_db()
|
||||
|
||||
self._allocate_cursor()
|
||||
cursor = self._get_cursor()
|
||||
|
||||
# execute script for creating tables
|
||||
with open(sql_file, 'r', encoding='utf-8') as fsql:
|
||||
cursor.executescript(fsql.read())
|
||||
# add default user in user table
|
||||
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
|
||||
username,
|
||||
utils.ComputePasswordHash(password),
|
||||
1,
|
||||
utils.GenerateSalt()
|
||||
))
|
||||
|
||||
self._free_cursor()
|
||||
|
||||
# commit to database
|
||||
db.commit()
|
||||
|
||||
def close(self):
|
||||
if (self.db is None):
|
||||
LOGGER.warning('Try to close null database.')
|
||||
else:
|
||||
self._free_cursor()
|
||||
self.db.close()
|
||||
self.db = None
|
||||
|
||||
def _get_db(self) -> sqlite3.Connection:
|
||||
if (self.db is None):
|
||||
raise DbException('There is no opened database')
|
||||
else:
|
||||
return self.db
|
||||
|
||||
def _allocate_cursor(self) -> None:
|
||||
if (self.cursor is not None):
|
||||
raise DbException('There is already opened database cursor')
|
||||
else:
|
||||
self.cursor = self._get_db().cursor()
|
||||
|
||||
def _get_cursor(self) -> sqlite3.Cursor:
|
||||
if (self.cursor is None):
|
||||
raise DbException('There is no opened database cursor')
|
||||
else:
|
||||
return self.cursor
|
||||
|
||||
def _free_cursor(self) -> None:
|
||||
if (self.cursor is None):
|
||||
LOGGER.warning('Try to free null databse cursor.')
|
||||
else:
|
||||
self.cursor.close()
|
||||
self.cursor = None
|
||||
|
||||
# ======================= token related internal operation
|
||||
def tokenOper_clean(self):
|
||||
# remove outdated token
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('DELETE FROM token WHERE [token_expire_on] <= ?',(utils.GetCurrentTimestamp(), ))
|
||||
|
||||
def tokenOper_postpone_expireOn(self, token):
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('UPDATE token SET [token_expire_on] = ? WHERE [token] = ?;', (
|
||||
utils.GetTokenExpireOn(),
|
||||
token
|
||||
))
|
||||
|
||||
def tokenOper_check_valid(self, token):
|
||||
self.tokenOper_get_username(token)
|
||||
|
||||
def tokenOper_is_admin(self, username):
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('SELECT [is_admin] FROM user WHERE [name] = ?;',(username, ))
|
||||
cache = cursor.fetchone()[0]
|
||||
return cache == 1
|
||||
|
||||
def tokenOper_get_username(self, token):
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('SELECT [user] FROM token WHERE [token] = ? AND [token_expire_on] > ?;',(
|
||||
token,
|
||||
utils.GetCurrentTimestamp()
|
||||
))
|
||||
result = cursor.fetchone()[0]
|
||||
# need postpone expire on time
|
||||
self.tokenOper_postpone_expireOn(token)
|
||||
return result
|
||||
|
||||
# =============================== # =============================== operation function
|
||||
# =============================== common
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_salt(self, username):
|
||||
cursor = self._get_cursor()
|
||||
salt = utils.GenerateSalt()
|
||||
cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
|
||||
salt,
|
||||
username
|
||||
))
|
||||
return salt
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_login(self, username, password, clientUa, clientIp):
|
||||
cursor = self._get_cursor()
|
||||
cursor.execute('SELECT [password], [salt] FROM user WHERE [name] = ?;', (username, ))
|
||||
(gotten_salt, gotten_password) = cursor.fetchone()
|
||||
|
||||
if password == utils.ComputePasswordHashWithSalt(gotten_password, gotten_salt):
|
||||
token = utils.GenerateToken(username)
|
||||
cursor.execute('UPDATE user SET [salt] = ? WHERE [name] = ?;', (
|
||||
utils.GenerateSalt(), # regenerate a new slat to prevent re-login try
|
||||
username
|
||||
))
|
||||
cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
|
||||
username,
|
||||
token,
|
||||
utils.GetTokenExpireOn(), # add 2 day from now
|
||||
clientUa,
|
||||
clientIp,
|
||||
))
|
||||
return token
|
||||
else:
|
||||
# throw a exception to indicate fail to login
|
||||
raise DbException('Login authentication failed')
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_webLogin(self, username, password, clientUa, clientIp):
|
||||
cursor = self._get_cursor()
|
||||
LOGGER.debug(f'WebLogin Username: {username}')
|
||||
LOGGER.debug(f'WebLogin Password: {password}')
|
||||
passwordHash = utils.ComputePasswordHash(password)
|
||||
LOGGER.debug(f'WebLogin Password Hash: {passwordHash}')
|
||||
|
||||
cursor.execute('SELECT [name] FROM user WHERE [name] = ? AND [password] = ?;', (username, passwordHash))
|
||||
|
||||
if len(cursor.fetchall()) != 0:
|
||||
token = utils.GenerateToken(username)
|
||||
cursor.execute('INSERT INTO token VALUES (?, ?, ?, ?, ?);', (
|
||||
username,
|
||||
token,
|
||||
utils.GetTokenExpireOn(), # add 2 day from now
|
||||
clientUa,
|
||||
clientIp,
|
||||
))
|
||||
return token
|
||||
else:
|
||||
# throw a exception to indicate fail to login
|
||||
raise DbException('Login authentication failed')
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_logout(self, token):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
cursor.execute('DELETE FROM token WHERE [token] = ?;', (token, ))
|
||||
return True
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def common_tokenValid(self, token):
|
||||
self.tokenOper_check_valid(token)
|
||||
return True
|
||||
|
||||
# =============================== calendar
|
||||
@SafeDatabaseOperation
|
||||
def calendar_getFull(self, token, startDateTime, endDateTime):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT calendar.* FROM calendar INNER JOIN collection \
|
||||
ON collection.uuid = calendar.belong_to \
|
||||
WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);',
|
||||
(username, startDateTime, endDateTime))
|
||||
return cursor.fetchall()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_getList(self, token, startDateTime, endDateTime):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT calendar.uuid FROM calendar INNER JOIN collection \
|
||||
ON collection.uuid = calendar.belong_to \
|
||||
WHERE (collection.user = ? AND calendar.loop_date_time_end >= ? AND calendar.loop_date_time_start - (calendar.event_date_time_end - calendar.event_date_time_start) <= ?);',
|
||||
(username, startDateTime, endDateTime))
|
||||
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_getDetail(self, token, uuid):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
cursor.execute('SELECT * FROM calendar WHERE [uuid] = ?;', (uuid, ))
|
||||
return cursor.fetchone()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_update(self, token, uuid, lastChange, **optArgs):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
# get prev data
|
||||
cursor.execute('SELECT * FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||
analyseData = list(cursor.fetchone())
|
||||
|
||||
# construct update data
|
||||
lastupdate = utils.GenerateUUID()
|
||||
sqlList = [
|
||||
'[last_change] = ?',
|
||||
]
|
||||
argumentsList = [
|
||||
lastupdate,
|
||||
]
|
||||
|
||||
# analyse opt arg
|
||||
reAnalyseLoop = False
|
||||
|
||||
cache = optArgs.get('belongTo', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[belong_to] = ?')
|
||||
argumentsList.append(cache)
|
||||
cache = optArgs.get('title', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[title] = ?')
|
||||
argumentsList.append(cache)
|
||||
cache = optArgs.get('description', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[description] = ?')
|
||||
argumentsList.append(cache)
|
||||
cache = optArgs.get('eventDateTimeStart', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[event_date_time_start] = ?')
|
||||
argumentsList.append(cache)
|
||||
reAnalyseLoop = True
|
||||
analyseData[5] = cache
|
||||
cache = optArgs.get('eventDateTimeEnd', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[event_date_time_end] = ?')
|
||||
argumentsList.append(cache)
|
||||
cache = optArgs.get('loopRules', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[loop_rules] = ?')
|
||||
argumentsList.append(cache)
|
||||
reAnalyseLoop = True
|
||||
analyseData[8] = cache
|
||||
cache = optArgs.get('timezoneOffset', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[timezone_offset] = ?')
|
||||
argumentsList.append(cache)
|
||||
reAnalyseLoop = True
|
||||
analyseData[7] = cache
|
||||
|
||||
if reAnalyseLoop:
|
||||
# re-compute loop data and upload it into list
|
||||
sqlList.append('[loop_date_time_start] = ?')
|
||||
argumentsList.append(analyseData[5])
|
||||
sqlList.append('[loop_date_time_end] = ?')
|
||||
argumentsList.append(str(dt.ResolveLoopStr(
|
||||
analyseData[8],
|
||||
analyseData[5],
|
||||
analyseData[7]
|
||||
)))
|
||||
|
||||
# execute
|
||||
argumentsList.append(uuid)
|
||||
cursor.execute('UPDATE calendar SET {} WHERE [uuid] = ?;'.format(', '.join(sqlList)),
|
||||
tuple(argumentsList))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||
return lastupdate
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_add(self, token, belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, loopRules, timezoneOffset):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
newuuid = utils.GenerateUUID()
|
||||
lastupdate = utils.GenerateUUID()
|
||||
|
||||
# analyse loopRules and output following 2 fileds.
|
||||
loopDateTimeStart = eventDateTimeStart
|
||||
loopDateTimeEnd = dt.ResolveLoopStr(loopRules, eventDateTimeStart, timezoneOffset)
|
||||
|
||||
cursor.execute('INSERT INTO calendar VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);',
|
||||
(newuuid,
|
||||
belongTo,
|
||||
title,
|
||||
description,
|
||||
lastupdate,
|
||||
eventDateTimeStart,
|
||||
eventDateTimeEnd,
|
||||
timezoneOffset,
|
||||
loopRules,
|
||||
loopDateTimeStart,
|
||||
loopDateTimeEnd))
|
||||
return newuuid
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def calendar_delete(self, token, uuid, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
cursor.execute('DELETE FROM calendar WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
# =============================== collection
|
||||
@SafeDatabaseOperation
|
||||
def collection_getFullOwn(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ?;', (username, ))
|
||||
return cursor.fetchall()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_getListOwn(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT [uuid] FROM collection WHERE [user] = ?;', (username, ))
|
||||
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_getDetailOwn(self, token, uuid):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT [uuid], [name], [last_change] FROM collection WHERE [user] = ? AND [uuid] = ?;', (username, uuid))
|
||||
return cursor.fetchone()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_addOwn(self, token, newname):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
newuuid = utils.GenerateUUID()
|
||||
lastupdate = utils.GenerateUUID()
|
||||
cursor.execute('INSERT INTO collection VALUES (?, ?, ?, ?);',
|
||||
(newuuid, newname, username, lastupdate))
|
||||
return newuuid
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_updateOwn(self, token, uuid, newname, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
lastupdate = utils.GenerateUUID()
|
||||
cursor.execute('UPDATE collection SET [name] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (
|
||||
newname,
|
||||
lastupdate,
|
||||
uuid,
|
||||
lastChange
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||
return lastupdate
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_deleteOwn(self, token, uuid, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
cursor.execute('DELETE FROM collection WHERE [uuid] = ? AND [last_change] = ?;', (
|
||||
uuid,
|
||||
lastChange
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_getSharing(self, token, uuid):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
cursor.execute('SELECT [target] FROM share WHERE [uuid] = ?;', (uuid, ))
|
||||
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_deleteSharing(self, token, uuid, target, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
lastupdate = utils.GenerateUUID()
|
||||
cursor.execute('UPDATE collection SET [last_change] = ?, WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
|
||||
cursor.execute('DELETE FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
|
||||
return lastupdate
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_addSharing(self, token, uuid, target, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
lastupdate = utils.GenerateUUID()
|
||||
cursor.execute('UPDATE collection SET [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (lastupdate, uuid, lastChange))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
|
||||
cursor.execute('SELECT * FROM share WHERE [uuid] = ? AND [target] = ?;', (uuid, target))
|
||||
if len(cursor.fetchall()) != 0:
|
||||
raise DbException('Fail to insert duplicated item.')
|
||||
cursor.execute('INSERT INTO share VALUES (?, ?);', (uuid, target))
|
||||
|
||||
return lastupdate
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def collection_getShared(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT collection.uuid, collection.name, collection.user \
|
||||
FROM share INNER JOIN collection \
|
||||
ON share.uuid = collection.uuid \
|
||||
WHERE share.target = ?;', (username, ))
|
||||
return cursor.fetchall()
|
||||
|
||||
# =============================== todo
|
||||
@SafeDatabaseOperation
|
||||
def todo_getFull(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT * FROM todo WHERE [belong_to] = ?;', (username, ))
|
||||
return cursor.fetchall()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_getList(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT [uuid] FROM todo WHERE [belong_to] = ?;', (username, ))
|
||||
return tuple(map(lambda x: x[0], cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_getDetail(self, token, uuid):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('SELECT * FROM todo WHERE [belong_to] = ? AND [uuid] = ?;', (username, uuid))
|
||||
return cursor.fetchone()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_add(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
newuuid = utils.GenerateUUID()
|
||||
lastupdate = utils.GenerateUUID()
|
||||
returnedData = (
|
||||
newuuid,
|
||||
username,
|
||||
'',
|
||||
lastupdate,
|
||||
)
|
||||
cursor.execute('INSERT INTO todo VALUES (?, ?, ?, ?);', returnedData)
|
||||
return returnedData
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_update(self, token, uuid, data, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
# check valid token
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
# update
|
||||
newLastChange = utils.GenerateUUID()
|
||||
cursor.execute('UPDATE todo SET [data] = ?, [last_change] = ? WHERE [uuid] = ? AND [last_change] = ?;', (
|
||||
data,
|
||||
newLastChange,
|
||||
uuid,
|
||||
lastChange
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||
return newLastChange
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def todo_delete(self, token, uuid, lastChange):
|
||||
cursor = self._get_cursor()
|
||||
# check valid token
|
||||
self.tokenOper_check_valid(token)
|
||||
|
||||
# delete
|
||||
cursor.execute('DELETE FROM todo WHERE [uuid] = ? AND [last_change] = ?;', (uuid, lastChange))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
|
||||
# =============================== admin
|
||||
@SafeDatabaseOperation
|
||||
def admin_get(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
if not self.tokenOper_is_admin(username):
|
||||
raise DbException('Permission denied.')
|
||||
|
||||
cursor.execute('SELECT [name], [is_admin] FROM user;')
|
||||
return tuple(map(lambda x: (x[0], x[1] == 1), cursor.fetchall()))
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def admin_add(self, token, newname):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
if not self.tokenOper_is_admin(username):
|
||||
raise DbException('Permission denied.')
|
||||
|
||||
newpassword = utils.ComputePasswordHash(utils.GenerateUUID())
|
||||
cursor.execute('INSERT INTO user VALUES (?, ?, ?, ?);', (
|
||||
newname,
|
||||
newpassword,
|
||||
0,
|
||||
utils.GenerateSalt()
|
||||
))
|
||||
return (newname, False)
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def admin_update(self, token, _username, **optArgs):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
if not self.tokenOper_is_admin(username):
|
||||
raise DbException('Permission denied.')
|
||||
|
||||
# construct data
|
||||
sqlList = []
|
||||
argumentsList = []
|
||||
|
||||
# analyse opt arg
|
||||
cache = optArgs.get('password', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[password] = ?')
|
||||
argumentsList.append(utils.ComputePasswordHash(cache))
|
||||
cache = optArgs.get('isAdmin', None)
|
||||
if cache is not None:
|
||||
sqlList.append('[is_admin] = ?')
|
||||
argumentsList.append(1 if cache else 0)
|
||||
|
||||
# execute
|
||||
argumentsList.append(_username)
|
||||
cursor.execute('UPDATE user SET {} WHERE [name] = ?;'.format(', '.join(sqlList)),
|
||||
tuple(argumentsList))
|
||||
LOGGER.debug(cache)
|
||||
LOGGER.debug(tuple(argumentsList))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to update due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def admin_delete(self, token, username):
|
||||
cursor = self._get_cursor()
|
||||
_username = self.tokenOper_get_username(token)
|
||||
if not self.tokenOper_is_admin(_username):
|
||||
raise DbException('Permission denied.')
|
||||
|
||||
# delete
|
||||
cursor.execute('DELETE FROM user WHERE [name] = ?;', (username, ))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
# =============================== profile
|
||||
@SafeDatabaseOperation
|
||||
def profile_isAdmin(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
return self.tokenOper_is_admin(username)
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def profile_changePassword(self, token, newpassword):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
cursor.execute('UPDATE user SET [password] = ? WHERE [name] = ?;', (
|
||||
utils.ComputePasswordHash(newpassword),
|
||||
username
|
||||
))
|
||||
return True
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def profile_getToken(self, token):
|
||||
cursor = self._get_cursor()
|
||||
username = self.tokenOper_get_username(token)
|
||||
|
||||
cursor.execute('SELECT * FROM token WHERE [user] = ?;', (
|
||||
username,
|
||||
))
|
||||
return cursor.fetchall()
|
||||
|
||||
@SafeDatabaseOperation
|
||||
def profile_deleteToken(self, token, deleteToken):
|
||||
cursor = self._get_cursor()
|
||||
_username = self.tokenOper_get_username(token)
|
||||
|
||||
# delete
|
||||
cursor.execute('DELETE FROM token WHERE [user] = ? AND [token] = ?;', (
|
||||
_username,
|
||||
deleteToken
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
raise DbException('Fail to delete due to no matched rows or too much rows.')
|
||||
return True
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import time
|
||||
import re
|
||||
import logging
|
||||
import typing
|
||||
from functools import reduce
|
||||
import utils
|
||||
|
||||
@@ -13,7 +14,9 @@ MAX_TIMESTAMP = int(MAX_DATETIME.timestamp() / 60)
|
||||
DAY1_SPAN = 60 * 24
|
||||
DAY7_SPAN = 7 * DAY1_SPAN
|
||||
|
||||
def ResolveLoopStr(strl, starttime, tzoffset):
|
||||
LoopHandle = typing.Callable[[re.Match, int, int, int], int]
|
||||
|
||||
def ResolveLoopStr(strl: str, starttime: int, tzoffset: int) -> int:
|
||||
# check no loop
|
||||
if strl == '':
|
||||
return starttime
|
||||
@@ -40,7 +43,7 @@ def ResolveLoopStr(strl, starttime, tzoffset):
|
||||
raise Exception('Invalid loopRules')
|
||||
|
||||
|
||||
def LoopHandle_Year(searchResult, starttime, times, tzoffset):
|
||||
def LoopHandle_Year(searchResult: re.Match, starttime: int, times: int, tzoffset: int) -> int:
|
||||
clientDate = datetime.datetime.fromtimestamp(starttime * 60, UTCTimezone(tzoffset))
|
||||
isStrict = searchResult.group(1) == 'S'
|
||||
yearSpan = int(searchResult.group(2))
|
||||
@@ -52,7 +55,7 @@ def LoopHandle_Year(searchResult, starttime, times, tzoffset):
|
||||
if clientMonth == 2 and clientDay == 29:
|
||||
if isStrict:
|
||||
realSpan = utils.LCM(yearSpan, 4)
|
||||
print(realSpan)
|
||||
logging.debug(realSpan)
|
||||
valCache = starttime
|
||||
while valCache < MAX_TIMESTAMP and times > 0:
|
||||
newYear += realSpan
|
||||
@@ -71,7 +74,7 @@ def LoopHandle_Year(searchResult, starttime, times, tzoffset):
|
||||
val = starttime + DAY1_SPAN * (DaysCount(newYear, newMonth, newDay) - DaysCount(clientYear, clientMonth, clientDay))
|
||||
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
|
||||
|
||||
def LoopHandle_Month(searchResult, starttime, times, tzoffset):
|
||||
def LoopHandle_Month(searchResult: re.Match, starttime: int, times: int, tzoffset: int) -> int:
|
||||
isStrict = searchResult.group(1) == 'S'
|
||||
loopType = searchResult.group(2)
|
||||
monthSpan = int(searchResult.group(3))
|
||||
@@ -144,7 +147,7 @@ def LoopHandle_Month(searchResult, starttime, times, tzoffset):
|
||||
val = starttime + DAY1_SPAN * (DaysCount(newYear, newMonth, newDay) - DaysCount(clientYear, clientMonth, clientDay))
|
||||
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
|
||||
|
||||
def LoopHandle_Week(searchResult, starttime, times, tzoffset):
|
||||
def LoopHandle_Week(searchResult: re.Match, starttime: int, times: int, tzoffset: int) -> int:
|
||||
weekOccupied = tuple(map(lambda x: x == 'T', searchResult.group(1)))
|
||||
weekEventCount = reduce(lambda x, y: x + (1 if y else 0), weekOccupied, 0)
|
||||
if weekEventCount == 0:
|
||||
@@ -170,25 +173,25 @@ def LoopHandle_Week(searchResult, starttime, times, tzoffset):
|
||||
val -= 1
|
||||
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
|
||||
|
||||
def LoopHandle_Day(searchResult, starttime, times, tzoffset):
|
||||
def LoopHandle_Day(searchResult: re.Match, starttime: int, times: int, tzoffset: int) -> int:
|
||||
val = starttime + DAY1_SPAN * times * int(searchResult.group(1))
|
||||
val -= 1
|
||||
return val if val < MAX_TIMESTAMP else MAX_TIMESTAMP
|
||||
|
||||
precompiledLoopRules = (
|
||||
precompiledLoopRules: tuple[tuple[re.Pattern, LoopHandle], ...] = (
|
||||
(re.compile(r'^Y([SR]{1})([1-9]\d*)$'), LoopHandle_Year),
|
||||
(re.compile(r'^M([SR]{1})([ABCD]{1})([1-9]\d*)$'), LoopHandle_Month),
|
||||
(re.compile(r'^W([TF]{7})([1-9]\d*)$'), LoopHandle_Week),
|
||||
(re.compile(r'^D([1-9]\d*)$'), LoopHandle_Day)
|
||||
)
|
||||
|
||||
precompiledLoopStopRules = {
|
||||
precompiledLoopStopRules: dict[str, re.Pattern] = {
|
||||
'infinity': re.compile(r'^F$'),
|
||||
'datetime': re.compile(r'^D([1-9]\d*|0)$'),
|
||||
'times': re.compile(r'^T([1-9]\d*)$')
|
||||
}
|
||||
|
||||
def LeapYearCountEx(endYear, includeThis = False, baseYear = 1, includeBase = True):
|
||||
def LeapYearCountEx(endYear: int, includeThis: bool = False, baseYear: int = 1, includeBase: bool = True):
|
||||
if not includeThis:
|
||||
endYear -= 1
|
||||
if includeBase:
|
||||
@@ -204,10 +207,10 @@ def LeapYearCountEx(endYear, includeThis = False, baseYear = 1, includeBase = Tr
|
||||
|
||||
return (endly - basely)
|
||||
|
||||
def LeapYearCount(year):
|
||||
def LeapYearCount(year: int):
|
||||
return LeapYearCountEx(year, False, 1, True)
|
||||
|
||||
def IsLeapYear(year):
|
||||
def IsLeapYear(year: int):
|
||||
isLeap = False
|
||||
if year % 4 == 0:
|
||||
isLeap = True
|
||||
@@ -217,7 +220,7 @@ def IsLeapYear(year):
|
||||
isLeap = True
|
||||
return isLeap
|
||||
|
||||
def DaysCount(year, month, day):
|
||||
def DaysCount(year: int, month: int, day: int):
|
||||
ly = LeapYearCountEx(year, False, 1, True)
|
||||
days = 365 * (year - 1)
|
||||
days += ly
|
||||
@@ -231,7 +234,7 @@ def DaysCount(year, month, day):
|
||||
days += day - 1
|
||||
return days
|
||||
|
||||
def DayOfWeek(year, month, day):
|
||||
def DayOfWeek(year: int, month: int, day: int):
|
||||
# as we know, 1/1/1900 is Monday.
|
||||
# via this method, we can got 1/1/1 is Monday
|
||||
# compute day span
|
||||
@@ -240,7 +243,7 @@ def DayOfWeek(year, month, day):
|
||||
# return day of week (from 0 - 6, corresponding with python)
|
||||
return days % 7
|
||||
|
||||
def GetDayInMonth(year, month, day):
|
||||
def GetDayInMonth(year: int, month: int, day: int):
|
||||
days = MonthDayCount[month - 1] + (1 if (month == 2 and IsLeapYear(year)) else 0)
|
||||
firstDayOfWeek = DayOfWeek(year, month, 1)
|
||||
dayOfWeek = (firstDayOfWeek + day - 1) % 7
|
||||
@@ -253,7 +256,7 @@ def GetDayInMonth(year, month, day):
|
||||
|
||||
return (dayForwards, dayBackwards, weeksForward, dayOfWeek, weeksBackwards, dayOfWeek)
|
||||
|
||||
def GetMonthWeekStatistics(year, month):
|
||||
def GetMonthWeekStatistics(year: int, month: int):
|
||||
days = MonthDayCount[month - 1] + (1 if (month == 2 and IsLeapYear(year)) else 0)
|
||||
firstDayOfWeek = DayOfWeek(year, month, 1)
|
||||
lastDayOfWeek = (firstDayOfWeek + days - 1) % 7
|
||||
@@ -269,14 +272,18 @@ def GetMonthWeekStatistics(year, month):
|
||||
return tuple(result)
|
||||
|
||||
class UTCTimezone(datetime.tzinfo):
|
||||
def __init__(self, offset = 0):
|
||||
self._offset = offset
|
||||
|
||||
__offset: int
|
||||
|
||||
def __init__(self, offset: int = 0):
|
||||
self.__offset = offset
|
||||
|
||||
def utcoffset(self, dt):
|
||||
return datetime.timedelta(minutes=self._offset)
|
||||
return datetime.timedelta(minutes=self.__offset)
|
||||
|
||||
def tzname(self, dt):
|
||||
return 'UTC {}'.format(self._offset)
|
||||
return 'UTC {}'.format(self.__offset)
|
||||
|
||||
def dst(self, dt):
|
||||
return datetime.timedelta(0)
|
||||
|
||||
42
backend/logger.py
Normal file
42
backend/logger.py
Normal file
@@ -0,0 +1,42 @@
|
||||
import logging
|
||||
import enum
|
||||
|
||||
|
||||
def _build_logger() -> tuple[logging.Logger, logging.Handler]:
|
||||
# Create a new logger which is independent with Flask
|
||||
logger = logging.getLogger("my_console_logger")
|
||||
# Avoid message was propagated to root logger or captured by Flask logger.
|
||||
logger.propagate = False
|
||||
# Set initial level.
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Create StreamHandler to output into stderr.
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
# Set format for it.
|
||||
formatter = logging.Formatter("[%(levelname)s] %(message)s")
|
||||
console_handler.setFormatter(formatter)
|
||||
# Add handler
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return (logger, console_handler)
|
||||
|
||||
|
||||
(LOGGER, CONSOLE_HANDLER) = _build_logger()
|
||||
|
||||
|
||||
class LoggerLevel(enum.IntEnum):
|
||||
DEBUG = enum.auto()
|
||||
INFO = enum.auto()
|
||||
|
||||
|
||||
def set_level(level: LoggerLevel) -> None:
|
||||
logging_level: int = logging.INFO
|
||||
match level:
|
||||
case LoggerLevel.DEBUG:
|
||||
logging_level = logging.DEBUG
|
||||
case LoggerLevel.INFO:
|
||||
logging_level = logging.INFO
|
||||
|
||||
LOGGER.setLevel(logging_level)
|
||||
CONSOLE_HANDLER.setLevel(logging_level)
|
||||
14
backend/pyproject.toml
Normal file
14
backend/pyproject.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "coleaf-backend"
|
||||
version = "1.1.0"
|
||||
description = "The backend of coconut-leaf."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"flask==2.2.3",
|
||||
]
|
||||
[tool.uv]
|
||||
constraint-dependencies = [
|
||||
"Werkzeug==2.2.2",
|
||||
"MarkupSafe==2.1.5"
|
||||
]
|
||||
513
backend/server.py
Normal file
513
backend/server.py
Normal file
@@ -0,0 +1,513 @@
|
||||
from flask import Flask
|
||||
from flask import request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, ParamSpec, TypeVar, Generic
|
||||
|
||||
import config
|
||||
import database
|
||||
import utils
|
||||
from logger import LOGGER
|
||||
from database import ResponseBody
|
||||
|
||||
app = Flask(__name__)
|
||||
calendar_db = database.CalendarDatabase()
|
||||
|
||||
# region: API Route
|
||||
|
||||
# region: Common
|
||||
|
||||
|
||||
@app.route("/common/salt", methods=["POST"])
|
||||
def api_common_saltHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_salt, (FormField("username", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/common/login", methods=["POST"])
|
||||
def api_common_loginHandle():
|
||||
clientInfo = FetchClientNetworkInfo()
|
||||
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_login,
|
||||
(
|
||||
FormField("username", str, False),
|
||||
FormField("password", str, False),
|
||||
FormField("clientUa", str, False),
|
||||
FormField("clientIp", str, False),
|
||||
),
|
||||
{"clientUa": clientInfo.user_agent, "clientIp": clientInfo.ip_addr},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/common/webLogin", methods=["POST"])
|
||||
def api_common_webLoginHandle():
|
||||
clientInfo = FetchClientNetworkInfo()
|
||||
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_webLogin,
|
||||
(
|
||||
FormField("username", str, False),
|
||||
FormField("password", str, False),
|
||||
FormField("clientUa", str, False),
|
||||
FormField("clientIp", str, False),
|
||||
),
|
||||
{"clientUa": clientInfo.user_agent, "clientIp": clientInfo.ip_addr},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/common/logout", methods=["POST"])
|
||||
def api_common_logoutHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_logout, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/common/tokenValid", methods=["POST"])
|
||||
def api_common_tokenValidHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.common_tokenValid, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Calendar
|
||||
|
||||
|
||||
@app.route("/calendar/getFull", methods=["POST"])
|
||||
def api_calendar_getFullHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_getFull,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("startDateTime", int, False),
|
||||
FormField("endDateTime", int, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/getList", methods=["POST"])
|
||||
def api_calendar_getListHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_getList,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("startDateTime", int, False),
|
||||
FormField("endDateTime", int, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/getDetail", methods=["POST"])
|
||||
def api_calendar_getDetailHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_getDetail,
|
||||
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/update", methods=["POST"])
|
||||
def api_calendar_updateHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_update,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("belongTo", str, True),
|
||||
FormField("title", str, True),
|
||||
FormField("description", str, True),
|
||||
FormField("eventDateTimeStart", int, True),
|
||||
FormField("eventDateTimeEnd", int, True),
|
||||
FormField("loopRules", str, True),
|
||||
FormField("timezoneOffset", int, True),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/add", methods=["POST"])
|
||||
def api_calendar_addHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_add,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("belongTo", str, False),
|
||||
FormField("title", str, False),
|
||||
FormField("description", str, False),
|
||||
FormField("eventDateTimeStart", int, False),
|
||||
FormField("eventDateTimeEnd", int, False),
|
||||
FormField("loopRules", str, False),
|
||||
FormField("timezoneOffset", int, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/calendar/delete", methods=["POST"])
|
||||
def api_calendar_deleteHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.calendar_delete,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Collection
|
||||
|
||||
|
||||
@app.route("/collection/getFullOwn", methods=["POST"])
|
||||
def api_collection_getFullOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getFullOwn, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/getListOwn", methods=["POST"])
|
||||
def api_collection_getListOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getListOwn, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/getDetailOwn", methods=["POST"])
|
||||
def api_collection_getDetailOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getDetailOwn,
|
||||
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/addOwn", methods=["POST"])
|
||||
def api_collection_addOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_addOwn,
|
||||
(FormField("token", str, False), FormField("name", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/updateOwn", methods=["POST"])
|
||||
def api_collection_updateOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_updateOwn,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("name", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/deleteOwn", methods=["POST"])
|
||||
def api_collection_deleteOwnHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_deleteOwn,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/getSharing", methods=["POST"])
|
||||
def api_collection_getSharingHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getSharing,
|
||||
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/deleteSharing", methods=["POST"])
|
||||
def api_collection_deleteSharingHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_deleteSharing,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("target", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/addSharing", methods=["POST"])
|
||||
def api_collection_addSharingHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_addSharing,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("target", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/collection/getShared", methods=["POST"])
|
||||
def api_collection_getSharedHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.collection_getShared, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Todo
|
||||
|
||||
|
||||
@app.route("/todo/getFull", methods=["POST"])
|
||||
def api_todo_getFullHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_getFull, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/todo/getList", methods=["POST"])
|
||||
def api_todo_getListHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_getList, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/todo/getDetail", methods=["POST"])
|
||||
def api_todo_getDetailHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_getDetail,
|
||||
(FormField("token", str, False), FormField("uuid", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/todo/add", methods=["POST"])
|
||||
def api_todo_addHandle():
|
||||
return SmartDbCaller(calendar_db.todo_add, (FormField("token", str, False),), None)
|
||||
|
||||
|
||||
@app.route("/todo/update", methods=["POST"])
|
||||
def api_todo_updateHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_update,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("data", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/todo/delete", methods=["POST"])
|
||||
def api_todo_deleteHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.todo_delete,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("uuid", str, False),
|
||||
FormField("lastChange", str, False),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Admin
|
||||
|
||||
|
||||
@app.route("/admin/get", methods=["POST"])
|
||||
def api_admin_getHandle():
|
||||
return SmartDbCaller(calendar_db.admin_get, (FormField("token", str, False),), None)
|
||||
|
||||
|
||||
@app.route("/admin/add", methods=["POST"])
|
||||
def api_admin_addHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.admin_add,
|
||||
(FormField("token", str, False), FormField("username", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/update", methods=["POST"])
|
||||
def api_admin_updateHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.admin_update,
|
||||
(
|
||||
FormField("token", str, False),
|
||||
FormField("username", str, False),
|
||||
FormField("password", str, True),
|
||||
FormField("isAdmin", utils.Str2Bool, True),
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/admin/delete", methods=["POST"])
|
||||
def api_admin_deleteHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.admin_delete,
|
||||
(FormField("token", str, False), FormField("username", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Profile
|
||||
|
||||
|
||||
@app.route("/profile/isAdmin", methods=["POST"])
|
||||
def api_profile_isAdminHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.profile_isAdmin, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/profile/changePassword", methods=["POST"])
|
||||
def api_profile_changePasswordHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.profile_changePassword,
|
||||
(FormField("token", str, False), FormField("password", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/profile/getToken", methods=["POST"])
|
||||
def api_profile_getTokenHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.profile_getToken, (FormField("token", str, False),), None
|
||||
)
|
||||
|
||||
|
||||
@app.route("/profile/deleteToken", methods=["POST"])
|
||||
def api_profile_deleteTokenHandle():
|
||||
return SmartDbCaller(
|
||||
calendar_db.profile_deleteToken,
|
||||
(FormField("token", str, False), FormField("deleteToken", str, False)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# endregion
|
||||
|
||||
# region: Utilities
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientNetworkInfo:
|
||||
user_agent: str
|
||||
"""The user agent of client."""
|
||||
ip_addr: str
|
||||
"""The IP address of client."""
|
||||
|
||||
|
||||
def FetchClientNetworkInfo() -> ClientNetworkInfo:
|
||||
clientUa = request.user_agent.string
|
||||
forwardIpList = request.headers.getlist("X-Forwarded-For")
|
||||
if forwardIpList:
|
||||
clientIp = forwardIpList[0]
|
||||
else:
|
||||
directIp = request.remote_addr
|
||||
if directIp is not None:
|
||||
clientIp = directIp
|
||||
else:
|
||||
clientIp = "0.0.0.0"
|
||||
|
||||
return ClientNetworkInfo(clientUa, clientIp)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FormField:
|
||||
name: str
|
||||
"""The name of form field."""
|
||||
ty: Callable[[str], Any]
|
||||
"""The type of form field."""
|
||||
is_optional: bool
|
||||
"""True if this form field is optional, otherwise false."""
|
||||
|
||||
|
||||
def SmartDbCaller(
|
||||
db_method: Callable[..., ResponseBody[Any]],
|
||||
fields: tuple[FormField, ...],
|
||||
padding_form: dict[str, str] | None,
|
||||
) -> dict[str, Any]:
|
||||
opt_param_counter = 0
|
||||
lost_required: bool = False
|
||||
param_list: list[Any] = []
|
||||
opt_param_dict: dict[str, Any] = {}
|
||||
|
||||
# fetch user passed form
|
||||
user_form: dict[str, str] = request.form.to_dict()
|
||||
LOGGER.debug(f"User Form: {user_form}")
|
||||
# overwrite user form by our padding form
|
||||
if padding_form is not None:
|
||||
user_form.update(padding_form)
|
||||
LOGGER.debug(f"Padded User Form: {user_form}")
|
||||
|
||||
# check fields one by one
|
||||
for field in fields:
|
||||
value = user_form.get(field.name, None)
|
||||
if value is not None:
|
||||
value = field.ty(value)
|
||||
|
||||
if field.is_optional:
|
||||
# optional param
|
||||
if value is not None:
|
||||
opt_param_dict[field.name] = value
|
||||
opt_param_counter += 1
|
||||
else:
|
||||
# required param
|
||||
if value is None:
|
||||
lost_required = True
|
||||
else:
|
||||
param_list.append(value)
|
||||
|
||||
# Only execute database function if there is no lost required fields.
|
||||
# And fulfill one of following requirements:
|
||||
# 1. There are all required fields (optional parameter count is zero).
|
||||
# 1. Or, there is some optional parameter.
|
||||
LOGGER.debug(f"Has Lost Required Parameter: {lost_required}")
|
||||
LOGGER.debug(f"All Optional Parameter Count: {opt_param_counter}")
|
||||
LOGGER.debug(f"Available Optional Parameter Count: {len(opt_param_dict)}")
|
||||
result: ResponseBody[Any]
|
||||
if lost_required == False and (opt_param_counter == 0 or len(opt_param_dict) != 0):
|
||||
result = db_method(*param_list, **opt_param_dict)
|
||||
else:
|
||||
result = ResponseBody(False, "Invalid parameter", None)
|
||||
|
||||
return ConstructResponseBody(result)
|
||||
|
||||
|
||||
def ConstructResponseBody(body: ResponseBody[Any]) -> dict[str, Any]:
|
||||
return {"success": body.success, "error": body.error, "data": body.data}
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
def run():
|
||||
calendar_db.open()
|
||||
app.run(port=config.get_config().web.port)
|
||||
calendar_db.close()
|
||||
0
backend/sql/mysql.sql
Normal file
0
backend/sql/mysql.sql
Normal file
67
backend/sql/sqlite.sql
Normal file
67
backend/sql/sqlite.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
CREATE TABLE user(
|
||||
[name] TEXT NOT NULL,
|
||||
[password] TEXT NOT NULL,
|
||||
[is_admin] TINYINT NOT NULL CHECK(is_admin = 1 OR is_admin = 0),
|
||||
[salt] INTEGER NOT NULL,
|
||||
|
||||
PRIMARY KEY (name)
|
||||
);
|
||||
|
||||
CREATE TABLE token(
|
||||
[user] TEXT NOT NULL,
|
||||
[token] TEXT UNIQUE NOT NULL,
|
||||
[token_expire_on] BIGINT NOT NULL,
|
||||
[ua] TEXT NOT NULL,
|
||||
[ip] TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE collection(
|
||||
[uuid] TEXT NOT NULL,
|
||||
[name] TEXT NOT NULL,
|
||||
[user] TEXT NOT NULL,
|
||||
[last_change] TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (uuid),
|
||||
FOREIGN KEY (user) REFERENCES user(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE share(
|
||||
[uuid] TEXT NOT NULL,
|
||||
[target] TEXT NOT NULL,
|
||||
|
||||
FOREIGN KEY (uuid) REFERENCES collection(uuid) ON DELETE CASCADE
|
||||
FOREIGN KEY (target) REFERENCES user(name) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE calendar(
|
||||
[uuid] TEXT NOT NULL,
|
||||
[belong_to] TEXT NOT NULL,
|
||||
|
||||
[title] TEXT NOT NULL,
|
||||
[description] TEXT NOT NULL,
|
||||
[last_change] TEXT NOT NULL,
|
||||
|
||||
[event_date_time_start] BIGINT NOT NULL,
|
||||
[event_date_time_end] BIGINT NOT NULL,
|
||||
[timezone_offset] INT NOT NULL,
|
||||
|
||||
[loop_rules] TEXT NOT NULL,
|
||||
[loop_date_time_start] BIGINT NOT NULL,
|
||||
[loop_date_time_end] BIGINT NOT NULL,
|
||||
|
||||
PRIMARY KEY (uuid),
|
||||
FOREIGN KEY (belong_to) REFERENCES collection(uuid) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE todo(
|
||||
[uuid] TEXT NOT NULL,
|
||||
[belong_to] TEXT NOT NULL,
|
||||
|
||||
[data] TEXT NOT NULL,
|
||||
[last_change] TEXT NOT NULL,
|
||||
|
||||
PRIMARY KEY (uuid),
|
||||
FOREIGN KEY (belong_to) REFERENCES user(name) ON DELETE CASCADE
|
||||
);
|
||||
64
backend/utils.py
Normal file
64
backend/utils.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import hashlib
|
||||
import random
|
||||
import uuid
|
||||
import time
|
||||
import math
|
||||
import re
|
||||
|
||||
USERNAME_PATTERN: re.Pattern = re.compile("^[0-9A-Za-z]+$")
|
||||
PASSWORD_PATTERN: re.Pattern = re.compile("^[!-~]+$")
|
||||
|
||||
|
||||
def IsValidUsername(strl: str) -> bool:
|
||||
return USERNAME_PATTERN.match(strl) is not None
|
||||
|
||||
|
||||
def IsValidPassword(strl: str) -> bool:
|
||||
return PASSWORD_PATTERN.match(strl) is not None
|
||||
|
||||
|
||||
def ComputePasswordHash(password: str) -> str:
|
||||
s = hashlib.sha256()
|
||||
s.update(password.encode("utf-8"))
|
||||
return s.hexdigest()
|
||||
|
||||
|
||||
def GenerateUUID() -> str:
|
||||
return str(uuid.uuid1())
|
||||
|
||||
|
||||
def GenerateToken(username: str) -> str:
|
||||
s = hashlib.sha256()
|
||||
s.update(username.encode("utf-8"))
|
||||
s.update(GenerateUUID().encode("utf-8"))
|
||||
return s.hexdigest()
|
||||
|
||||
|
||||
def GenerateSalt() -> int:
|
||||
return random.randint(0, 6172748)
|
||||
|
||||
|
||||
def ComputePasswordHashWithSalt(passwordHashed: str, salt: int) -> str:
|
||||
s = hashlib.sha256()
|
||||
s.update((passwordHashed + str(salt)).encode("utf-8"))
|
||||
return s.hexdigest()
|
||||
|
||||
|
||||
def GetCurrentTimestamp() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def GetTokenExpireOn() -> int:
|
||||
return GetCurrentTimestamp() + 60 * 60 * 24 * 2 # add 2 day from now
|
||||
|
||||
|
||||
def Str2Bool(strl: str) -> bool:
|
||||
return strl.lower() == "true"
|
||||
|
||||
|
||||
def GCD(a: int, b: int) -> int:
|
||||
return math.gcd(a, b)
|
||||
|
||||
|
||||
def LCM(a: int, b: int) -> int:
|
||||
return (a * b) // GCD(a, b)
|
||||
117
backend/uv.lock
generated
Normal file
117
backend/uv.lock
generated
Normal file
@@ -0,0 +1,117 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[manifest]
|
||||
constraints = [
|
||||
{ name = "markupsafe", specifier = "==2.1.5" },
|
||||
{ name = "werkzeug", specifier = "==2.2.2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coleaf-backend"
|
||||
version = "1.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "flask" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "flask", specifier = "==2.2.3" }]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "2.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/5c/ff9047989bd995b1098d14b03013f160225db2282925b517bb4a967752ee/Flask-2.2.3.tar.gz", hash = "sha256:7eb373984bf1c770023fce9db164ed0c3353cd0b53f130f4693da0ca756a2e6d", size = 697599, upload-time = "2023-02-15T22:43:57.265Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/a3542594ce4973786236a1b7b702b8ca81dbf40ea270f0f96284f0c27348/Flask-2.2.3-py3-none-any.whl", hash = "sha256:c0bec9477df1cb867e5a67c9e1ab758de9cb4a3e52dd70681f59fa40a62b3f2d", size = 101839, upload-time = "2023-02-15T22:43:55.501Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jinja2"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "2.1.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/87/5b/aae44c6655f3801e81aa3eef09dbbf012431987ba564d7231722f68df02d/MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b", size = 19384, upload-time = "2024-02-02T16:31:22.863Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/e7/291e55127bb2ae67c64d66cef01432b5933859dfb7d6949daa721b89d0b3/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f", size = 18219, upload-time = "2024-02-02T16:30:19.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/cb/aed7a284c00dfa7c0682d14df85ad4955a350a21d2e3b06d8240497359bf/MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2", size = 14098, upload-time = "2024-02-02T16:30:21.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/cf/35fe557e53709e93feb65575c93927942087e9b97213eabc3fe9d5b25a55/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced", size = 29014, upload-time = "2024-02-02T16:30:22.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/18/c30da5e7a0e7f4603abfc6780574131221d9148f323752c2755d48abad30/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5", size = 28220, upload-time = "2024-02-02T16:30:24.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/40/2e73e7d532d030b1e41180807a80d564eda53babaf04d65e15c1cf897e40/MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c", size = 27756, upload-time = "2024-02-02T16:30:25.877Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/46/5dca760547e8c59c5311b332f70605d24c99d1303dd9a6e1fc3ed0d73561/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f", size = 33988, upload-time = "2024-02-02T16:30:26.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/c5/27febe918ac36397919cd4a67d5579cbbfa8da027fa1238af6285bb368ea/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a", size = 32718, upload-time = "2024-02-02T16:30:28.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/81/56e567126a2c2bc2684d6391332e357589a96a76cb9f8e5052d85cb0ead8/MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f", size = 33317, upload-time = "2024-02-02T16:30:29.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/0b/23f4b2470accb53285c613a3ab9ec19dc944eaf53592cb6d9e2af8aa24cc/MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906", size = 16670, upload-time = "2024-02-02T16:30:30.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/a2/c78a06a9ec6d04b3445a949615c4c7ed86a0b2eb68e44e7541b9d57067cc/MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617", size = 17224, upload-time = "2024-02-02T16:30:32.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/bd/583bf3e4c8d6a321938c13f49d44024dbe5ed63e0a7ba127e454a66da974/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1", size = 18215, upload-time = "2024-02-02T16:30:33.081Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d6/e7cd795fc710292c3af3a06d80868ce4b02bfbbf370b7cee11d282815a2a/MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4", size = 14069, upload-time = "2024-02-02T16:30:34.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/b5/5d8ec796e2a08fc814a2c7d2584b55f889a55cf17dd1a90f2beb70744e5c/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee", size = 29452, upload-time = "2024-02-02T16:30:35.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/0d/2454f072fae3b5a137c119abf15465d1771319dfe9e4acbb31722a0fff91/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5", size = 28462, upload-time = "2024-02-02T16:30:36.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/75/fd6cb2e68780f72d47e6671840ca517bda5ef663d30ada7616b0462ad1e3/MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b", size = 27869, upload-time = "2024-02-02T16:30:37.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/81/147c477391c2750e8fc7705829f7351cf1cd3be64406edcf900dc633feb2/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a", size = 33906, upload-time = "2024-02-02T16:30:39.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ff/9a52b71839d7a256b563e85d11050e307121000dcebc97df120176b3ad93/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f", size = 32296, upload-time = "2024-02-02T16:30:40.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/07/2dc76aa51b481eb96a4c3198894f38b480490e834479611a4053fbf08623/MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169", size = 33038, upload-time = "2024-02-02T16:30:42.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0c/620c1fb3661858c0e37eb3cbffd8c6f732a67cd97296f725789679801b31/MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad", size = 16572, upload-time = "2024-02-02T16:30:43.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127, upload-time = "2024-02-02T16:30:44.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "2.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/c1/1c8e539f040acd80f844c69a5ef8e2fccdf8b442dabb969e497b55d544e1/Werkzeug-2.2.2.tar.gz", hash = "sha256:7ea2d48322cc7c0f8b3a215ed73eabd7b5d75d0b50e31ab006286ccff9e00b8f", size = 844378, upload-time = "2022-08-08T21:44:15.376Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/27/be6ddbcf60115305205de79c29004a0c6bc53cec814f733467b1bb89386d/Werkzeug-2.2.2-py3-none-any.whl", hash = "sha256:f979ab81f58d7318e064e99c4506445d60135ac5cd2e177a2de0089bfd4c9bd5", size = 232700, upload-time = "2022-08-08T21:44:13.251Z" },
|
||||
]
|
||||
@@ -29,6 +29,9 @@ div.perfectTable > div > div {
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
div.perfectTable > div {
|
||||
@@ -36,6 +39,11 @@ div.perfectTable > div {
|
||||
flex-flow: row;
|
||||
}
|
||||
|
||||
div.perfectTable > div > div[picked=true] {
|
||||
background: hsl(171, 100%, 41%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -64,6 +72,8 @@ div.pickerContainer > svg {
|
||||
div.pickerContainer > svg > text {
|
||||
dominant-baseline: middle;
|
||||
text-anchor: middle;
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
div.pickerContainer > svg > circle[type=background] {
|
||||
@@ -90,7 +100,7 @@ header.pickerHeader {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
|
||||
flex-grow: 1;
|
||||
flex-grow: 0;
|
||||
flex-basis: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
@@ -63,7 +63,6 @@ ccn-i18n-login-form-login=Login
|
||||
|
||||
ccn-i18n-todo-todoList=Todo list
|
||||
|
||||
ccn-i18n-calendar-calendar-jump=Jump
|
||||
ccn-i18n-calendar-calendar-today=Today
|
||||
ccn-i18n-calendar-calendar-add=Add...
|
||||
ccn-i18n-calendar-calendar-stripedEvents={0} items
|
||||
@@ -63,7 +63,6 @@ ccn-i18n-login-form-login=登录
|
||||
|
||||
ccn-i18n-todo-todoList=待办列表
|
||||
|
||||
ccn-i18n-calendar-calendar-jump=转到
|
||||
ccn-i18n-calendar-calendar-today=今天
|
||||
ccn-i18n-calendar-calendar-add=添加...
|
||||
ccn-i18n-calendar-calendar-stripedEvents=共{0}项
|
||||
@@ -139,3 +138,16 @@ ccn-i18n-tokenItem-ua=UA:
|
||||
ccn-i18n-tokenItem-ip=IP:
|
||||
ccn-i18n-tokenItem-expireOn=过期时间:
|
||||
ccn-i18n-tokenItem-isMe=这是你当前使用的登录凭据
|
||||
|
||||
ccn-i18n-datetime-loopStopRuleText-infinity=永远循环。
|
||||
ccn-i18n-datetime-loopStopRuleText-datetime=到{0}停止循环。
|
||||
ccn-i18n-datetime-loopStopRuleText-times=循环{0}次。
|
||||
ccn-i18n-datetime-loopRuleText-modeStrict=严格模式。
|
||||
ccn-i18n-datetime-loopRuleText-modeRough=宽松模式。
|
||||
ccn-i18n-datetime-loopRuleText-year=每{0}年于{1}循环一次。
|
||||
ccn-i18n-datetime-loopRuleText-monthA=每{0}月的第{1}日循环一次。
|
||||
ccn-i18n-datetime-loopRuleText-monthB=每{0}月的倒数第{1}日循环一次。
|
||||
ccn-i18n-datetime-loopRuleText-monthC=每{0}月的第{1}个星期{2}循环一次。
|
||||
ccn-i18n-datetime-loopRuleText-monthD=每{0}月的倒数第{1}个星期{2}循环一次。
|
||||
ccn-i18n-datetime-loopRuleText-week=每{0}周的{1}循环一次。
|
||||
ccn-i18n-datetime-loopRuleText-day=每{0}天循环一次。
|
||||
BIN
frontend-legacy/static/image/icon.png
Normal file
BIN
frontend-legacy/static/image/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -2,9 +2,9 @@
|
||||
var ccn_datetime_monthDayCount = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
||||
|
||||
var ccn_datetime_MIN_YEAR = 1950;
|
||||
var ccn_datetime_MAX_YEAR = 2199;
|
||||
var ccn_datetime_MIN_DATETIME = new Date(Date.UTC(1950, 1, 1, 0, 0, 0, 0));
|
||||
var ccn_datetime_MAX_DATETIME = new Date(Date.UTC(2200, 1, 1, 0, 0, 0, 0));
|
||||
var ccn_datetime_MAX_YEAR = 2200;
|
||||
var ccn_datetime_MIN_DATETIME = new Date(Date.UTC(ccn_datetime_MIN_YEAR, 0, 1, 0, 0, 0, 0));
|
||||
var ccn_datetime_MAX_DATETIME = new Date(Date.UTC(ccn_datetime_MAX_YEAR, 0, 1, 0, 0, 0, 0));
|
||||
var ccn_datetime_MIN_TIMESTAMP = Math.floor(ccn_datetime_MIN_DATETIME.getTime() / 60000);
|
||||
var ccn_datetime_MAX_TIMESTAMP = Math.floor(ccn_datetime_MAX_DATETIME.getTime() / 60000);
|
||||
|
||||
@@ -261,8 +261,76 @@ function ccn_datetime_ResolveLoopRules4Event(loopRules, loopDateTimeStart, loopD
|
||||
return realResult;
|
||||
}
|
||||
|
||||
function ccn_datetime_ResolveLoopRules4Text(loopRules) {
|
||||
return "";
|
||||
function ccn_datetime_ResolveLoopRules4Text(strl, startDateTime, timezoneOffset) {
|
||||
if (strl == '') return "";
|
||||
|
||||
var sp = strl.split('-');
|
||||
if (sp.length != 2) return "";
|
||||
var loopRules = undefined;
|
||||
var loopStopRules = undefined;
|
||||
var datetimeInstance = new Date((startDateTime + timezoneOffset) * 60000)
|
||||
|
||||
if (ccn_datetime_precompiledLoopRules.year.test(sp[0])) {
|
||||
if (RegExp.$1 == 'S')
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-modeStrict');
|
||||
else
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-modeRough');
|
||||
loopRules += $.i18n.prop('ccn-i18n-datetime-loopRuleText-year')
|
||||
.format(parseInt(RegExp.$2), datetimeInstance.toLocaleDateString(undefined, {timeZone: "UTC"}));
|
||||
} else if (ccn_datetime_precompiledLoopRules.month.test(sp[0])) {
|
||||
if (RegExp.$1 == 'S')
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-modeStrict');
|
||||
else
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-modeRough');
|
||||
|
||||
var dayInMonth = ccn_datetime_GetDayInMonth(
|
||||
datetimeInstance.getUTCFullYear(),
|
||||
datetimeInstance.getUTCMonth() + 1,
|
||||
datetimeInstance.getUTCDate());
|
||||
switch(RegExp.$2) {
|
||||
case 'A':
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-monthA')
|
||||
.format(parseInt(RegExp.$3), dayInMonth[0]);
|
||||
break;
|
||||
case 'B':
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-monthB')
|
||||
.format(parseInt(RegExp.$3), dayInMonth[1]);
|
||||
break;
|
||||
case 'C':
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-monthC')
|
||||
.format(parseInt(RegExp.$3), dayInMonth[2], dayInMonth[3]);
|
||||
break;
|
||||
case 'D':
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-monthD')
|
||||
.format(parseInt(RegExp.$3), dayInMonth[4], dayInMonth[5]);
|
||||
break;
|
||||
}
|
||||
} else if (ccn_datetime_precompiledLoopRules.week.test(sp[0])) {
|
||||
var weekOfDayCache = [];
|
||||
for (var i = 0; i < 7; i++) {
|
||||
if (RegExp.$1[i] == 'T')
|
||||
weekOfDayCache.push(ccn_i18n_UniversalGetDayOfWeek(i));
|
||||
}
|
||||
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-week')
|
||||
.format(parseInt(RegExp.$2), weekOfDayCache.join(', '));
|
||||
} else if (ccn_datetime_precompiledLoopRules.day.test(sp[0])) {
|
||||
loopRules = $.i18n.prop('ccn-i18n-datetime-loopRuleText-day')
|
||||
.format(parseInt(RegExp.$1));
|
||||
} else return "";
|
||||
|
||||
|
||||
if (ccn_datetime_precompiledLoopStopRules.infinity.test(sp[1])) {
|
||||
loopStopRules = $.i18n.prop('ccn-i18n-datetime-loopStopRuleText-infinity');
|
||||
} else if (ccn_datetime_precompiledLoopStopRules.datetime.test(sp[1])) {
|
||||
loopStopRules = $.i18n.prop('ccn-i18n-datetime-loopStopRuleText-datetime')
|
||||
.format(new Date(parseInt(RegExp.$1)).toLocaleDateString());
|
||||
} else if (ccn_datetime_precompiledLoopStopRules.times.test(sp[1])) {
|
||||
loopStopRules = $.i18n.prop('ccn-i18n-datetime-loopStopRuleText-times')
|
||||
.format(parseInt(RegExp.$1));
|
||||
} else return "";
|
||||
|
||||
return (loopRules + loopStopRules);
|
||||
}
|
||||
|
||||
function ccn_datetime_LeapYearCountEx(endYear, includeThis, baseYear, includeBase) {
|
||||
522
frontend-legacy/static/js/datetimepicker.js
Normal file
522
frontend-legacy/static/js/datetimepicker.js
Normal file
@@ -0,0 +1,522 @@
|
||||
var ccn_datetimepicker_tabType = {
|
||||
year: 0,
|
||||
month: 1,
|
||||
day: 2,
|
||||
hour: 3,
|
||||
minute: 4
|
||||
};
|
||||
|
||||
var ccn_datetimepicker_dialPlateWidth = 200;
|
||||
var ccn_datetimepicker_dialPlateRadius = ccn_datetimepicker_dialPlateWidth / 2;
|
||||
var ccn_datetimepicker_dialPlateHourInnerPercent = 0.6;
|
||||
var ccn_datetimepicker_dialPlateHourOutterPercent = 0.8;
|
||||
var ccn_datetimepicker_dialPlateHourDistinguishPercent = 0.7;
|
||||
var ccn_datetimepicker_dialPlateMinutePercent = 0.8;
|
||||
var ccn_datetimepicker_dialPlateHourResolution = Math.PI * 2 / 12;
|
||||
var ccn_datetimepicker_dialPlateMinuteResolution = Math.PI * 2 / 60;
|
||||
|
||||
var ccn_datetimepicker_mode = undefined;
|
||||
var ccn_datetimepicker_isUTC = undefined;
|
||||
var ccn_datetimepicker_pickerIndex = undefined;
|
||||
|
||||
var ccn_datetimepicker_enableMinuteDrag = false;
|
||||
var ccn_datetimepicker_enableHourDrag = false;
|
||||
|
||||
var ccn_datetimepicker_internalDateTime = new Date();
|
||||
var ccn_datetimepicker_displayCacheDateTime = new Date();
|
||||
|
||||
// ========================================= export func
|
||||
|
||||
function ccn_datetimepicker_Insert() {
|
||||
$('body').append(ccn_template_datetimepicker.render());
|
||||
|
||||
// bind size event and trigge once
|
||||
$(window).resize(ccn_datetimepicker_RefreshSvg).resize();
|
||||
|
||||
// add data attr
|
||||
for(var i = 0; i < 3; i++) {
|
||||
for(var j = 0; j < 4; j++) {
|
||||
$('#ccn-datetimepiacker-panelMonth-table > div:nth-child({0}) > div:nth-child({1})'.format(i + 1, j + 1))
|
||||
.attr('data', i * 4 + j);
|
||||
}
|
||||
}
|
||||
|
||||
// bind header event
|
||||
$('header.pickerHeader > div').click(function() {
|
||||
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_Str2TabType($(this).attr('type')));
|
||||
});
|
||||
|
||||
// bind button event
|
||||
$('#ccn-datetimepiacker-panelYear-prevBtn').click(function() {
|
||||
ccn_datetimepicker_PrevNextYear(true);
|
||||
});
|
||||
$('#ccn-datetimepiacker-panelYear-nextBtn').click(function() {
|
||||
ccn_datetimepicker_PrevNextYear(false);
|
||||
});
|
||||
$('#ccn-datetimepiacker-panelMonth-prevBtn').click(function() {
|
||||
ccn_datetimepicker_PrevNextMonth(true);
|
||||
});
|
||||
$('#ccn-datetimepiacker-panelMonth-nextBtn').click(function() {
|
||||
ccn_datetimepicker_PrevNextMonth(false);
|
||||
});
|
||||
$('#ccn-datetimepiacker-panelDay-prevBtn').click(function() {
|
||||
ccn_datetimepicker_PrevNextDay(true);
|
||||
});
|
||||
$('#ccn-datetimepiacker-panelDay-nextBtn').click(function() {
|
||||
ccn_datetimepicker_PrevNextDay(false);
|
||||
});
|
||||
|
||||
$('#ccn-datetimepiacker-panelYear-table > div > div').click(ccn_datetimepicker_ClickYear);
|
||||
$('#ccn-datetimepiacker-panelMonth-table > div > div').click(ccn_datetimepicker_ClickMonth);
|
||||
$('#ccn-datetimepiacker-panelDay-table > div:nth-child(n+1) > div').click(ccn_datetimepicker_ClickDay);
|
||||
|
||||
$('#ccn-datetimepicker-panelHour')
|
||||
.mousedown(ccn_datetimepicker_StartDragHour)
|
||||
.mousemove(ccn_datetimepicker_DraggingHour)
|
||||
.mouseup(ccn_datetimepicker_StopDragHour)
|
||||
.on('touchstart', ccn_datetimepicker_StartDragHour)
|
||||
.on('touchmove', ccn_datetimepicker_DraggingHour)
|
||||
.on('touchend', ccn_datetimepicker_StopDragHour);
|
||||
|
||||
$('#ccn-datetimepicker-panelMinute')
|
||||
.mousedown(ccn_datetimepicker_StartDragMinute)
|
||||
.mousemove(ccn_datetimepicker_DraggingMinute)
|
||||
.mouseup(ccn_datetimepicker_StopDragMinute)
|
||||
.on('touchstart', ccn_datetimepicker_StartDragMinute)
|
||||
.on('touchmove', ccn_datetimepicker_DraggingMinute)
|
||||
.on('touchend', ccn_datetimepicker_StopDragMinute);
|
||||
|
||||
$('#ccn-datetimepicker-btnConfirm').click(ccn_datetimepicker_Confirm);
|
||||
$('#ccn-datetimepicker-btnCancel').click(ccn_datetimepicker_Cancel);
|
||||
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_Modal(mode, pickerIndex, isUTC) {
|
||||
ccn_datetimepicker_mode = mode;
|
||||
ccn_datetimepicker_isUTC = isUTC;
|
||||
ccn_datetimepicker_pickerIndex = pickerIndex;
|
||||
|
||||
ccn_datetimepicker_internalDateTime = ccn_datetimepicker_Get(pickerIndex, false);
|
||||
|
||||
$('header.pickerHeader > div').hide();
|
||||
switch(mode) {
|
||||
case ccn_datetimepicker_tabType.minute:
|
||||
$('header.pickerHeader > div[type=minute]').show();
|
||||
case ccn_datetimepicker_tabType.hour:
|
||||
$('header.pickerHeader > div[type=hour]').show();
|
||||
case ccn_datetimepicker_tabType.day:
|
||||
$('header.pickerHeader > div[type=day]').show();
|
||||
case ccn_datetimepicker_tabType.month:
|
||||
$('header.pickerHeader > div[type=month]').show();
|
||||
case ccn_datetimepicker_tabType.year:
|
||||
$('header.pickerHeader > div[type=year]').show();
|
||||
break;
|
||||
}
|
||||
|
||||
$('#ccn-datetimepicker-modal').addClass('is-active');
|
||||
ccn_datetimepicker_SwitchTab(mode); // this call is set in there by design. if you don't show the dialog, the call of svg resize will fail.
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_Confirm() {
|
||||
// update and call callback func
|
||||
ccn_datetimepicker_Set(
|
||||
ccn_datetimepicker_pickerIndex,
|
||||
ccn_datetimepicker_internalDateTime,
|
||||
ccn_datetimepicker_isUTC,
|
||||
ccn_datetimepicker_mode
|
||||
);
|
||||
|
||||
$('#ccn-datetimepicker-modal').removeClass('is-active');
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_Cancel() {
|
||||
$('#ccn-datetimepicker-modal').removeClass('is-active');
|
||||
}
|
||||
|
||||
// ========================================= internal func
|
||||
|
||||
function ccn_datetimepicker_OnSvgResize(ele) {
|
||||
var scale = 200 / Math.min(ele.width(), ele.height());
|
||||
ele.css('font-size', scale + 'em');
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_SwitchTab(newTab) {
|
||||
$('div.pickerContainer > *').hide();
|
||||
|
||||
ccn_datetimepicker_displayCacheDateTime.setTime(ccn_datetimepicker_internalDateTime.getTime());
|
||||
ccn_datetimepicker_RefreshDisplay(newTab);
|
||||
|
||||
switch(newTab) {
|
||||
case ccn_datetimepicker_tabType.year:
|
||||
$('#ccn-datetimepicker-panelYear').show();
|
||||
break;
|
||||
case ccn_datetimepicker_tabType.month:
|
||||
$('#ccn-datetimepicker-panelMonth').show();
|
||||
break;
|
||||
case ccn_datetimepicker_tabType.day:
|
||||
$('#ccn-datetimepicker-panelDay').show();
|
||||
break;
|
||||
case ccn_datetimepicker_tabType.hour:
|
||||
$('#ccn-datetimepicker-panelHour').show();
|
||||
ccn_datetimepicker_RefreshSvg(); // immediately trigger once svg resize
|
||||
break;
|
||||
case ccn_datetimepicker_tabType.minute:
|
||||
$('#ccn-datetimepicker-panelMinute').show();
|
||||
ccn_datetimepicker_RefreshSvg(); // immediately trigger once svg resize
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_RefreshDisplay(tab) {
|
||||
// header should be refreshed entirely
|
||||
$('#ccn-datetimepicker-datetime-year').text(ccn_datetimepicker_internalDateTime.getFullYear());
|
||||
$('#ccn-datetimepicker-datetime-month').text(ccn_datetimepicker_internalDateTime.getMonth() + 1);
|
||||
$('#ccn-datetimepicker-datetime-day').text(ccn_datetimepicker_internalDateTime.getDate());
|
||||
$('#ccn-datetimepicker-datetime-hour').text(ccn_datetimepicker_internalDateTime.getHours());
|
||||
$('#ccn-datetimepicker-datetime-minute').text(ccn_datetimepicker_internalDateTime.getMinutes());
|
||||
|
||||
// refresh tab according to specific `tab`
|
||||
switch(tab) {
|
||||
case ccn_datetimepicker_tabType.year:
|
||||
var startYear = Math.floor((ccn_datetimepicker_displayCacheDateTime.getFullYear() - ccn_datetime_MIN_YEAR) / 12) * 12 + ccn_datetime_MIN_YEAR;
|
||||
var counter = startYear;
|
||||
for(var i = 0; i < 3; i++) {
|
||||
for(var j = 0; j < 4; j++, counter++) {
|
||||
var ele = $('#ccn-datetimepiacker-panelYear-table > div:nth-child({0}) > div:nth-child({1})'.format(i + 1, j + 1));
|
||||
if (counter < ccn_datetime_MAX_YEAR) {
|
||||
ele.attr('data', counter)
|
||||
.text(counter);
|
||||
} else {
|
||||
ele.attr('data', '')
|
||||
.html(' ');
|
||||
}
|
||||
|
||||
if (counter == ccn_datetimepicker_internalDateTime.getFullYear()) ele.attr('picked', 'true');
|
||||
else ele.attr('picked', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
$('#ccn-datetimepiacker-panelYear-title')
|
||||
.text('{0} - {1}'.format(startYear, startYear + 12 < ccn_datetime_MAX_YEAR ? startYear + 12 : ccn_datetime_MAX_YEAR));
|
||||
|
||||
break;
|
||||
case ccn_datetimepicker_tabType.month:
|
||||
$('#ccn-datetimepiacker-panelMonth-table > div > div').attr('picked', 'false');
|
||||
if (ccn_datetimepicker_internalDateTime.getFullYear() == ccn_datetimepicker_displayCacheDateTime.getFullYear()) {
|
||||
var month = ccn_datetimepicker_internalDateTime.getMonth();
|
||||
$('#ccn-datetimepiacker-panelMonth-table > div:nth-child({0}) > div:nth-child({1})'.format(Math.floor(month / 4) + 1, (month % 4) + 1))
|
||||
.attr('picked', 'true');
|
||||
}
|
||||
|
||||
$('#ccn-datetimepiacker-panelMonth-title')
|
||||
.text(ccn_datetimepicker_displayCacheDateTime.getFullYear());
|
||||
|
||||
break;
|
||||
case ccn_datetimepicker_tabType.day:
|
||||
var gottenYear = ccn_datetimepicker_displayCacheDateTime.getFullYear();
|
||||
var gottenMonth = ccn_datetimepicker_displayCacheDateTime.getMonth() + 1;
|
||||
var counter = -ccn_datetime_DayOfWeek(gottenYear, gottenMonth, 1);
|
||||
var days = ccn_datetime_monthDayCount[gottenMonth - 1] + ((gottenMonth == 2 && ccn_datetime_IsLeapYear(gottenYear)) ? 1 : 0);
|
||||
for(var i = 0; i < 6; i++) {
|
||||
for(var j = 0; j < 7; j++, counter++) {
|
||||
var ele = $('#ccn-datetimepiacker-panelDay-table > div:nth-child({0}) > div:nth-child({1})'.format(i + 2, j + 1));
|
||||
if (counter < 0 || counter >= days) ele.attr('data', '').html(' ');
|
||||
else ele.attr('data', counter + 1).text(counter + 1);
|
||||
|
||||
if (counter + 1 == ccn_datetimepicker_internalDateTime.getDate()) ele.attr('picked', 'true');
|
||||
else ele.attr('picked', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
$('#ccn-datetimepiacker-panelDay-title')
|
||||
.text('{0} - {1}'.format(
|
||||
ccn_datetimepicker_displayCacheDateTime.getFullYear(),
|
||||
ccn_i18n_UniversalGetMonth(ccn_datetimepicker_displayCacheDateTime.getMonth())
|
||||
));
|
||||
|
||||
break;
|
||||
case ccn_datetimepicker_tabType.hour:
|
||||
var gottenHour = ccn_datetimepicker_displayCacheDateTime.getHours();
|
||||
var newX = Math.cos((3 - gottenHour) * Math.PI * 2 / 12);
|
||||
var newY = Math.sin((3 - gottenHour) * Math.PI * 2 / 12);
|
||||
var radius = ccn_datetimepicker_dialPlateRadius * (gottenHour < 12 ? ccn_datetimepicker_dialPlateHourOutterPercent : ccn_datetimepicker_dialPlateHourInnerPercent);
|
||||
newX = newX * radius + ccn_datetimepicker_dialPlateRadius;
|
||||
newY = (-newY * radius) + ccn_datetimepicker_dialPlateRadius;
|
||||
|
||||
$('#ccn-datetimepicker-panelHour > line')
|
||||
.attr('x2', newX)
|
||||
.attr('y2', newY);
|
||||
|
||||
$('#ccn-datetimepicker-panelHour > circle[type=symbol]')
|
||||
.attr('cx', newX)
|
||||
.attr('cy', newY);
|
||||
|
||||
break;
|
||||
case ccn_datetimepicker_tabType.minute:
|
||||
var gottenMinute = ccn_datetimepicker_displayCacheDateTime.getMinutes();
|
||||
var newX = Math.cos((15 - gottenMinute) * Math.PI * 2 / 60);
|
||||
var newY = Math.sin((15 - gottenMinute) * Math.PI * 2 / 60);
|
||||
var radius = ccn_datetimepicker_dialPlateRadius * ccn_datetimepicker_dialPlateMinutePercent;
|
||||
newX = newX * radius + ccn_datetimepicker_dialPlateRadius;
|
||||
newY = (-newY * radius) + ccn_datetimepicker_dialPlateRadius;
|
||||
|
||||
$('#ccn-datetimepicker-panelMinute > line')
|
||||
.attr('x2', newX)
|
||||
.attr('y2', newY);
|
||||
|
||||
$('#ccn-datetimepicker-panelMinute > circle[type=symbol]')
|
||||
.attr('cx', newX)
|
||||
.attr('cy', newY);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_RefreshSvg() {
|
||||
// svg resize only can be called when the svg is showing.
|
||||
// so call this func in window resize event or
|
||||
// displaying svg.
|
||||
$('div.pickerContainer > svg').each(function() {
|
||||
ccn_datetimepicker_OnSvgResize($(this));
|
||||
});
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_Str2TabType(strl) {
|
||||
switch(strl) {
|
||||
case 'year':
|
||||
return ccn_datetimepicker_tabType.year
|
||||
case 'month':
|
||||
return ccn_datetimepicker_tabType.month
|
||||
case 'day':
|
||||
return ccn_datetimepicker_tabType.day
|
||||
case 'hour':
|
||||
return ccn_datetimepicker_tabType.hour
|
||||
case 'minute':
|
||||
return ccn_datetimepicker_tabType.minute
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_GetUniformedXY(mouseOrTouchEvent, elements) {
|
||||
var offset = {
|
||||
left: elements.offset().left,
|
||||
top: elements.offset().top,
|
||||
halfWidth: elements.width() / 2,
|
||||
halfHeight: elements.height() / 2,
|
||||
halfSquareWidthHeight: Math.min(elements.width(), elements.height()) / 2
|
||||
}
|
||||
if(typeof(mouseOrTouchEvent.pageX) != 'undefined' && typeof(mouseOrTouchEvent.pageY) != 'undefined') {
|
||||
offset.realX = mouseOrTouchEvent.pageX;
|
||||
offset.realY = mouseOrTouchEvent.pageY;
|
||||
} else if(typeof(mouseOrTouchEvent.targetTouches) != 'undefined' && mouseOrTouchEvent.targetTouches.length >= 1) {
|
||||
offset.realX = mouseOrTouchEvent.targetTouches[0].pageX;
|
||||
offset.realY = mouseOrTouchEvent.targetTouches[0].pageY;
|
||||
} else {
|
||||
offset.realX = 0;
|
||||
offset.realY = 0;
|
||||
}
|
||||
|
||||
var _x = (offset.realX - offset.left - offset.halfWidth) / offset.halfSquareWidthHeight * ccn_datetimepicker_dialPlateRadius;
|
||||
var _y = -((offset.realY - offset.top - offset.halfHeight) / offset.halfSquareWidthHeight * ccn_datetimepicker_dialPlateRadius);
|
||||
|
||||
return {x: _x, y: _y};
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_PrevNextYear(isPrev) {
|
||||
ccn_datetimepicker_displayCacheDateTime.setFullYear(
|
||||
ccn_datetimepicker_displayCacheDateTime.getFullYear() + (isPrev ? -12 : 12));
|
||||
|
||||
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_displayCacheDateTime);
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.year);
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_PrevNextMonth(isPrev) {
|
||||
ccn_datetimepicker_displayCacheDateTime.setFullYear(
|
||||
ccn_datetimepicker_displayCacheDateTime.getFullYear() + (isPrev ? -1 : 1));
|
||||
|
||||
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_displayCacheDateTime);
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.month);
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_PrevNextDay(isPrev) {
|
||||
ccn_datetimepicker_displayCacheDateTime.setMonth(
|
||||
ccn_datetimepicker_displayCacheDateTime.getMonth() + (isPrev ? -1 : 1));
|
||||
|
||||
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_displayCacheDateTime);
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.day);
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_ClickYear() {
|
||||
var ele = $(this);
|
||||
if (ele.attr('data') == '') return;
|
||||
|
||||
ccn_datetimepicker_internalDateTime.setFullYear(parseInt(ele.attr('data')));
|
||||
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
|
||||
|
||||
if (ccn_datetimepicker_mode != ccn_datetimepicker_tabType.year)
|
||||
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_tabType.month);
|
||||
else
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.year);
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_ClickMonth() {
|
||||
var ele = $(this);
|
||||
if (ele.attr('data') == '') return;
|
||||
|
||||
ccn_datetimepicker_internalDateTime.setFullYear(
|
||||
ccn_datetimepicker_displayCacheDateTime.getFullYear(),
|
||||
parseInt(ele.attr('data'))
|
||||
);
|
||||
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
|
||||
|
||||
if (ccn_datetimepicker_mode != ccn_datetimepicker_tabType.month)
|
||||
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_tabType.day);
|
||||
else
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.month);
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_ClickDay() {
|
||||
var ele = $(this);
|
||||
if (ele.attr('data') == '') return;
|
||||
|
||||
ccn_datetimepicker_internalDateTime.setFullYear(
|
||||
ccn_datetimepicker_displayCacheDateTime.getFullYear(),
|
||||
ccn_datetimepicker_displayCacheDateTime.getMonth(),
|
||||
parseInt(ele.attr('data'))
|
||||
);
|
||||
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
|
||||
|
||||
if (ccn_datetimepicker_mode != ccn_datetimepicker_tabType.day)
|
||||
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_tabType.hour);
|
||||
else
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.day);
|
||||
}
|
||||
|
||||
|
||||
function ccn_datetimepicker_StartDragHour() { ccn_datetimepicker_enableHourDrag = true; }
|
||||
function ccn_datetimepicker_DraggingHour(e) {
|
||||
if (!ccn_datetimepicker_enableHourDrag) return;
|
||||
|
||||
var offset = ccn_datetimepicker_GetUniformedXY(e, $('#ccn-datetimepicker-panelHour'));
|
||||
var x = offset.x;
|
||||
var y = offset.y;
|
||||
|
||||
var distance = Math.sqrt(x * x + y * y);
|
||||
var angle = Math.acos(x / distance);
|
||||
if (y < 0) angle = Math.PI * 2 - angle; // correct negative y axis angle
|
||||
|
||||
angle += (ccn_datetimepicker_dialPlateHourResolution / 2); // correct offset
|
||||
if (angle > Math.PI * 2)
|
||||
angle -= Math.PI * 2;
|
||||
|
||||
var number = Math.floor(angle / ccn_datetimepicker_dialPlateHourResolution);
|
||||
if (number >= 12) number = 11; // prevent unexpected result at the edge.
|
||||
number = (15 - number) % 12;
|
||||
if (distance < ccn_datetimepicker_dialPlateRadius * ccn_datetimepicker_dialPlateHourDistinguishPercent)
|
||||
number += 12;
|
||||
|
||||
// judge
|
||||
if (ccn_datetimepicker_displayCacheDateTime.getHours() != number) {
|
||||
ccn_datetimepicker_displayCacheDateTime.setHours(number);
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.hour);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
function ccn_datetimepicker_StopDragHour() {
|
||||
ccn_datetimepicker_enableHourDrag = false;
|
||||
|
||||
ccn_datetimepicker_internalDateTime.setHours(ccn_datetimepicker_displayCacheDateTime.getHours());
|
||||
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
|
||||
|
||||
if (ccn_datetimepicker_mode != ccn_datetimepicker_tabType.hour)
|
||||
ccn_datetimepicker_SwitchTab(ccn_datetimepicker_tabType.minute);
|
||||
}
|
||||
|
||||
|
||||
function ccn_datetimepicker_StartDragMinute() { ccn_datetimepicker_enableMinuteDrag = true; }
|
||||
function ccn_datetimepicker_DraggingMinute(e) {
|
||||
if (!ccn_datetimepicker_enableMinuteDrag) return;
|
||||
|
||||
var offset = ccn_datetimepicker_GetUniformedXY(e, $('#ccn-datetimepicker-panelMinute'));
|
||||
var x = offset.x;
|
||||
var y = offset.y;
|
||||
|
||||
var distance = Math.sqrt(x * x + y * y);
|
||||
var angle = Math.acos(x / distance);
|
||||
if (y < 0) angle = Math.PI * 2 - angle; // correct negative y axis angle
|
||||
|
||||
angle += (ccn_datetimepicker_dialPlateMinuteResolution / 2); // correct offset
|
||||
if (angle > Math.PI * 2)
|
||||
angle -= Math.PI * 2;
|
||||
|
||||
var number = Math.floor(angle / ccn_datetimepicker_dialPlateMinuteResolution);
|
||||
if (number >= 60) number = 59; // prevent unexpected result at the edge.
|
||||
number = (75 - number) % 60;
|
||||
|
||||
// judge
|
||||
if (ccn_datetimepicker_displayCacheDateTime.getMinutes() != number) {
|
||||
ccn_datetimepicker_displayCacheDateTime.setMinutes(number);
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.minute);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
function ccn_datetimepicker_StopDragMinute() {
|
||||
ccn_datetimepicker_enableMinuteDrag = false;
|
||||
|
||||
ccn_datetimepicker_internalDateTime.setMinutes(ccn_datetimepicker_displayCacheDateTime.getMinutes());
|
||||
ccn_datetimepicker_ClampDateTime(ccn_datetimepicker_internalDateTime);
|
||||
|
||||
// no page need to go to
|
||||
// but we need refresh current page
|
||||
ccn_datetimepicker_RefreshDisplay(ccn_datetimepicker_tabType.minute);
|
||||
}
|
||||
|
||||
|
||||
function ccn_datetimepicker_ClampDateTime(dateObj) {
|
||||
if (dateObj < ccn_datetime_MIN_DATETIME)
|
||||
dateObj.setTime(ccn_datetime_MIN_DATETIME.getTime());
|
||||
if (dateObj >= ccn_datetime_MAX_DATETIME)
|
||||
dateObj.setTime(ccn_datetime_MAX_DATETIME.getTime());
|
||||
}
|
||||
|
||||
// ========================================================== universal function
|
||||
|
||||
function ccn_datetimepicker_Set(pickerIndex, dt, isUTC, mode) {
|
||||
var ele = $('[datetimepicker=' + pickerIndex + ']');
|
||||
while(true) {
|
||||
if (mode < ccn_datetimepicker_tabType.year) break;
|
||||
ele.attr('datetimepicker-year', isUTC ? dt.getUTCFullYear() : dt.getFullYear());
|
||||
if (mode < ccn_datetimepicker_tabType.month) break;
|
||||
ele.attr('datetimepicker-month', (isUTC ? dt.getUTCMonth() : dt.getMonth()) + 1);
|
||||
if (mode < ccn_datetimepicker_tabType.day) break;
|
||||
ele.attr('datetimepicker-day', isUTC ? dt.getUTCDate() : dt.getDate());
|
||||
if (mode < ccn_datetimepicker_tabType.hour) break;
|
||||
ele.attr('datetimepicker-hour', isUTC ? dt.getUTCHours() : dt.getHours());
|
||||
if (mode < ccn_datetimepicker_tabType.minute) break;
|
||||
ele.attr('datetimepicker-minute', isUTC ? dt.getUTCMinutes() : dt.getMinutes());
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (typeof(ele.prop('funcs')) != 'undefined' && typeof(ele.prop('funcs').callback) == 'function')
|
||||
ele.prop('funcs').callback();
|
||||
}
|
||||
|
||||
function ccn_datetimepicker_Get(pickerIndex, isUTC) {
|
||||
var ele = $('[datetimepicker=' + pickerIndex + ']');
|
||||
year = ele.attr('datetimepicker-year');
|
||||
month = ele.attr('datetimepicker-month');
|
||||
day = ele.attr('datetimepicker-day');
|
||||
hour = ele.attr('datetimepicker-hour');
|
||||
minute = ele.attr('datetimepicker-minute');
|
||||
if (IsUndefinedOrEmpty(year)) year = ccn_datetime_MIN_YEAR;
|
||||
if (IsUndefinedOrEmpty(month)) month = 1;
|
||||
if (IsUndefinedOrEmpty(day)) day = 1;
|
||||
if (IsUndefinedOrEmpty(hour)) hour = 0;
|
||||
if (IsUndefinedOrEmpty(minute)) minute = 0;
|
||||
|
||||
if (isUTC) return new Date(Date.UTC(year, parseInt(month) - 1, day, hour, minute, 0, 0));
|
||||
else return new Date(year, parseInt(month) - 1, day, hour, minute, 0, 0);
|
||||
}
|
||||
@@ -76,3 +76,14 @@ function ccn_i18n_ApplyLanguage2Content(ctx) {
|
||||
$(this).html($.i18n.prop($(this).attr('i18n-name')));
|
||||
});
|
||||
}
|
||||
|
||||
// note: month is zero based
|
||||
function ccn_i18n_UniversalGetMonth(month) {
|
||||
return $.i18n.prop('ccn-i18n-universal-month-' + (month + 1));
|
||||
}
|
||||
|
||||
// note: day of week is zero based
|
||||
function ccn_i18n_UniversalGetDayOfWeek(dayOfWeek) {
|
||||
return $.i18n.prop('ccn-i18n-universal-week-' + (dayOfWeek + 1));
|
||||
}
|
||||
|
||||
@@ -30,9 +30,10 @@ $(document).ready(function() {
|
||||
// process calendar it self
|
||||
ccn_calendar_calendar_LoadCalendarBody();
|
||||
|
||||
// init datetimepicker
|
||||
// init datetimepicker and preset
|
||||
ccn_datetimepicker_Insert();
|
||||
ccn_datetimepicker_Init();
|
||||
var nowtime = new Date();
|
||||
ccn_datetimepicker_Set(1, nowtime, false, ccn_datetimepicker_tabType.month);
|
||||
|
||||
// bind tab control switcher and set current tab
|
||||
$("#tabcontrol-tab-1-1").click(function(){
|
||||
@@ -59,7 +60,14 @@ $(document).ready(function() {
|
||||
// bind event
|
||||
$('#ccn-calendar-collection-btnRefresh').click(ccn_calendar_collection_Refresh);
|
||||
|
||||
$('#ccn-calendar-calendar-btnJump').click(ccn_calendar_calendar_btnRefresh);
|
||||
$('#ccn-calendar-calendar-btnJump')
|
||||
.prop('funcs', {callback: ccn_calendar_calendar_btnRefresh})
|
||||
.click(function() {
|
||||
ccn_datetimepicker_Modal(
|
||||
ccn_datetimepicker_tabType.month,
|
||||
1,
|
||||
false);
|
||||
});
|
||||
$('#ccn-calendar-calendar-btnToday').click(ccn_calendar_calendar_btnToday);
|
||||
$('#ccn-calendar-calendar-btnAdd').click(ccn_calendar_calendar_btnAdd);
|
||||
});
|
||||
@@ -75,6 +83,7 @@ function ccn_calendar_calendar_Refresh() {
|
||||
var gottenDateTime = ccn_datetimepicker_Get(1, false);
|
||||
var gottenYear = gottenDateTime.getFullYear();
|
||||
var gottenMonth = gottenDateTime.getMonth() + 1;
|
||||
$('#ccn-calendar-calendar-textMonth').text('{0} - {1}'.format(gottenYear, ccn_i18n_UniversalGetMonth(gottenMonth - 1)));
|
||||
// don't need to set anything, because its default value is enough to use.
|
||||
|
||||
var gottenWeek = ccn_datetime_DayOfWeek(gottenYear, gottenMonth, 1);
|
||||
@@ -148,13 +157,13 @@ function ccn_calendar_calendar_Analyse() {
|
||||
color: deserializedDescription.color,
|
||||
isVisible: true,
|
||||
isLocked: typeof(ccn_calendar_owned_displayCache[item[0]]) != 'undefined',
|
||||
loopText: " ", // todo: finish this
|
||||
loopText: ccn_datetime_ResolveLoopRules4Text(item[8], item[5], item[7]),
|
||||
timezoneWarning: mytimezone != item[7],
|
||||
start: eventDateTime.toLocaleTimeString(),
|
||||
end: undefined // filled in follwing code
|
||||
}
|
||||
eventDateTime.setHours(23, 59, 0, 0);
|
||||
if (Math.floor(eventDateTime.getTime() / 60000) > it[1]) {
|
||||
if (it[1] <= Math.floor(eventDateTime.getTime() / 60000)) {
|
||||
exitFlag = true;
|
||||
eventDateTime.setTime(it[1] * 60000);
|
||||
}
|
||||
@@ -248,7 +257,7 @@ function ccn_calendar_calendar_btnRefresh() {
|
||||
|
||||
function ccn_calendar_calendar_btnToday() {
|
||||
var nowtime = new Date();
|
||||
ccn_datetimepicker_Set(1, nowtime, false);
|
||||
ccn_datetimepicker_Set(1, nowtime, false, ccn_datetimepicker_tabType.month);
|
||||
ccn_calendar_calendar_Refresh();
|
||||
ccn_calendar_calendar_Analyse();
|
||||
ccn_calendar_calendar_Render();
|
||||
@@ -20,7 +20,6 @@ $(document).ready(function() {
|
||||
|
||||
// init datetimepicker
|
||||
ccn_datetimepicker_Insert();
|
||||
ccn_datetimepicker_Init();
|
||||
|
||||
// apply i18n
|
||||
ccn_i18n_LoadLanguage();
|
||||
@@ -29,15 +28,23 @@ $(document).ready(function() {
|
||||
// bind event
|
||||
$('input[type=radio][name=loop-method]').click(ccn_event_RefreshRadioDiaplay);
|
||||
$('input[type=radio][name=loop-end]').click(ccn_event_RefreshRadioDiaplay);
|
||||
$('.datetimepicker-year[datetimepicker=1],.datetimepicker-month[datetimepicker=1],.datetimepicker-day[datetimepicker=1]').bind(
|
||||
'input propertychange',
|
||||
ccn_event_RefreshLoopMonthType
|
||||
);
|
||||
|
||||
$('#ccn-event-btnSubmit').click(ccn_event_btnSubmit);
|
||||
$('#ccn-event-btnCancel').click(ccn_event_btnCancel);
|
||||
$('#ccn-event-btnSpot').click(ccn_event_btnSpot);
|
||||
$('#ccn-event-btnFullDay').click(ccn_event_btnFullDay);
|
||||
$('#ccn-event-btnStartDateTime')
|
||||
.prop('funcs', {callback: function() {
|
||||
ccn_event_UpdateDateTimePickerButton(1);
|
||||
ccn_event_RefreshLoopMonthType();
|
||||
}})
|
||||
.click(ccn_event_btnDateTimePicker);
|
||||
$('#ccn-event-btnEndDateTime')
|
||||
.prop('funcs', {callback: function() {ccn_event_UpdateDateTimePickerButton(2);}})
|
||||
.click(ccn_event_btnDateTimePicker);
|
||||
$('#ccn-event-btnLoopStopDateTime')
|
||||
.prop('funcs', {callback: function() {ccn_event_UpdateDateTimePickerButton(3);}})
|
||||
.click(ccn_event_btnDateTimePicker);
|
||||
|
||||
// init form
|
||||
ccn_event_Init();
|
||||
@@ -61,9 +68,6 @@ function ccn_event_Init() {
|
||||
.attr('step', 1)
|
||||
.val(1);
|
||||
|
||||
// now, init 3 datetimepicker
|
||||
//ccn_datetimepicker_Init();
|
||||
|
||||
// in there, we need get uuid from meta
|
||||
var uuid = $('meta[name=uuid]').attr('content');
|
||||
if (uuid != "")
|
||||
@@ -243,6 +247,26 @@ function ccn_event_RefreshLoopMonthType() {
|
||||
$('#ccn-event-loopMonth-textD').text($.i18n.prop('ccn-i18n-event-loopWeek-optionD').format(data[4], data[5] + 1));
|
||||
}
|
||||
|
||||
function ccn_event_UpdateDateTimePickerButton(index) {
|
||||
switch(index) {
|
||||
case 1:
|
||||
$('#ccn-event-btnStartDateTime-text').text(
|
||||
ccn_datetimepicker_Get(1, false).toLocaleString()
|
||||
);
|
||||
break;
|
||||
case 2:
|
||||
$('#ccn-event-btnEndDateTime-text').text(
|
||||
ccn_datetimepicker_Get(2, false).toLocaleString()
|
||||
);
|
||||
break;
|
||||
case 3:
|
||||
$('#ccn-event-btnLoopStopDateTime-text').text(
|
||||
ccn_datetimepicker_Get(3, false).toLocaleDateString()
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// return undefined to indicate an error
|
||||
// or
|
||||
// [belongTo, title, description, eventDateTimeStart, eventDateTimeEnd, timezoneOffset, loopRules]
|
||||
@@ -366,6 +390,20 @@ function ccn_event_btnFullDay() {
|
||||
ccn_datetimepicker_Set(2, datetime, false);
|
||||
}
|
||||
|
||||
function ccn_event_btnDateTimePicker() {
|
||||
switch(parseInt($(this).attr('datetimepicker'))) {
|
||||
case 1:
|
||||
ccn_datetimepicker_Modal(ccn_datetimepicker_tabType.minute, 1, false);
|
||||
break;
|
||||
case 2:
|
||||
ccn_datetimepicker_Modal(ccn_datetimepicker_tabType.minute, 2, false);
|
||||
break;
|
||||
case 3:
|
||||
ccn_datetimepicker_Modal(ccn_datetimepicker_tabType.day, 3, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function ccn_event_btnCancel() {
|
||||
window.location.href = '/web/calendar';
|
||||
}
|
||||
@@ -1,74 +1,74 @@
|
||||
<div id="ccn-datetimepicker-modal" class="modal is-active" style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<div id="ccn-datetimepicker-modal" class="modal" style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card" style="height: 70%;">
|
||||
<header class="modal-card-head pickerHeader">
|
||||
<div><small i18n-name="ccn-i18n-universal-text-year"></small><span id="ccn-datetimepicker-datetime-year"> </span></div>
|
||||
<div><small i18n-name="ccn-i18n-universal-text-month"></small><span id="ccn-datetimepicker-datetime-month"> </span></div>
|
||||
<div><small i18n-name="ccn-i18n-universal-text-day"></small><span id="ccn-datetimepicker-datetime-day"> </span></div>
|
||||
<div><small i18n-name="ccn-i18n-universal-text-hour"></small><span id="ccn-datetimepicker-datetime-hour"> </span></div>
|
||||
<div><small i18n-name="ccn-i18n-universal-text-minute"></small><span id="ccn-datetimepicker-datetime-minute"> </span></div>
|
||||
<div type="year"><small i18n-name="ccn-i18n-universal-text-year"></small><span id="ccn-datetimepicker-datetime-year"> </span></div>
|
||||
<div type="month"><small i18n-name="ccn-i18n-universal-text-month"></small><span id="ccn-datetimepicker-datetime-month"> </span></div>
|
||||
<div type="day"><small i18n-name="ccn-i18n-universal-text-day"></small><span id="ccn-datetimepicker-datetime-day"> </span></div>
|
||||
<div type="hour"><small i18n-name="ccn-i18n-universal-text-hour"></small><span id="ccn-datetimepicker-datetime-hour"> </span></div>
|
||||
<div type="minute"><small i18n-name="ccn-i18n-universal-text-minute"></small><span id="ccn-datetimepicker-datetime-minute"> </span></div>
|
||||
</header>
|
||||
<div class="modal-card-body pickerContainer">
|
||||
<div id="ccn-datetimepicker-panel-year">
|
||||
<div id="ccn-datetimepicker-panelYear">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button">
|
||||
<a id="ccn-datetimepiacker-panelYear-prevBtn" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item">this is a title</div>
|
||||
<div id="ccn-datetimepiacker-panelYear-title" class="level-item"></div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button">
|
||||
<a id="ccn-datetimepiacker-panelYear-nextBtn" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="perfectTable">
|
||||
<div id="ccn-datetimepiacker-panelYear-table" class="perfectTable">
|
||||
<div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div>abc</div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ccn-datetimepicker-panel-month">
|
||||
<div id="ccn-datetimepicker-panelMonth">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button">
|
||||
<a id="ccn-datetimepiacker-panelMonth-prevBtn" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item">this is a title</div>
|
||||
<div id="ccn-datetimepiacker-panelMonth-title" class="level-item"></div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button">
|
||||
<a id="ccn-datetimepiacker-panelMonth-nextBtn" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="perfectTable">
|
||||
<div id="ccn-datetimepiacker-panelMonth-table" class="perfectTable">
|
||||
<div>
|
||||
<div i18n-name="ccn-i18n-universal-month-1"></div>
|
||||
<div i18n-name="ccn-i18n-universal-month-2"></div>
|
||||
@@ -89,26 +89,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ccn-datetimepicker-panel-day">
|
||||
<div id="ccn-datetimepicker-panelDay">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a class="button">
|
||||
<a id="ccn-datetimepiacker-panelDay-prevBtn" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-item">this is a title</div>
|
||||
<div id="ccn-datetimepiacker-panelDay-title" class="level-item"></div>
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a class="button">
|
||||
<a id="ccn-datetimepiacker-panelDay-nextBtn" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="perfectTable">
|
||||
<div id="ccn-datetimepiacker-panelDay-table" class="perfectTable">
|
||||
<div>
|
||||
<div i18n-name="ccn-i18n-universal-week-1"></div>
|
||||
<div i18n-name="ccn-i18n-universal-week-2"></div>
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg id="ccn-datetimepicker-panel-hour" xmlns="http://www.w3.org/2000/svg" version="1.1" preserveAspectRatio="xMidYMid" viewBox="0 0 200 200">
|
||||
<svg id="ccn-datetimepicker-panelHour" xmlns="http://www.w3.org/2000/svg" version="1.1" preserveAspectRatio="xMidYMid" viewBox="0 0 200 200">
|
||||
<circle cx="100.000000" cy="100.000000" r="100.000000" type="background"></circle>
|
||||
<line x1="100" y1="100" x2="100.000000" y2="20.000000"></line>
|
||||
<circle cx="100.000000" cy="20.000000" r="1em" type="symbol"></circle>
|
||||
@@ -168,7 +168,7 @@
|
||||
<text x="48.038476" y="70.000000">22</text>
|
||||
<text x="70.000000" y="48.038476">23</text>
|
||||
</svg>
|
||||
<svg id="ccn-datetimepicker-panel-minute" xmlns="http://www.w3.org/2000/svg" version="1.1" preserveAspectRatio="xMidYMid" viewBox="0 0 200 200">
|
||||
<svg id="ccn-datetimepicker-panelMinute" xmlns="http://www.w3.org/2000/svg" version="1.1" preserveAspectRatio="xMidYMid" viewBox="0 0 200 200">
|
||||
<circle cx="100.000000" cy="100.000000" r="100.000000" type="background"></circle>
|
||||
<line x1="100" y1="100" x2="100.000000" y2="20.000000"></line>
|
||||
<circle cx="100.000000" cy="20.000000" r="1em" type="symbol"></circle>
|
||||
@@ -15,6 +15,9 @@
|
||||
<p class="level-item"><b>{{>title}}</b></p>
|
||||
<p class="level-item">{{>description}}</p>
|
||||
<p class="level-item"><span>{{>start}}</span>-<span>{{>end}}</span></p>
|
||||
{{if loopText != ""}}
|
||||
<p><span class="icon is-small"><i class="fas fa-retweet"></i></span><span>{{>loopText}}</span></p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="schedule-event-icon">
|
||||
{{if isLocked}}
|
||||
@@ -22,6 +22,6 @@
|
||||
</div>
|
||||
|
||||
<div id="ccn-tokenItem-btnLogout-{{:uuid}}" uuid="{{:uuid}}" class="token-item-icon control">
|
||||
<a class="button"><span class="icon is-small"><i class="fas fa-sign-out"></i></span></a>
|
||||
<a class="button"><span class="icon is-small"><i class="fas fa-sign-out-alt"></i></span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title id="ccn-pageName"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title id="ccn-pageName"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
|
||||
@@ -52,38 +52,36 @@
|
||||
</div>
|
||||
|
||||
<div id="tabcontrol-panel-1-1" class="container tabcontrol-panel-1" style="margin-top: 20px;">
|
||||
<nav class="level">
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div class="level-item control">
|
||||
<a id="ccn-calendar-calendar-btnPrevMonth" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-left"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="level-item">
|
||||
<div class="field has-addons">
|
||||
<div class="control">
|
||||
<input datetimepicker="1" class="input datetimepicker-year" type="number">
|
||||
</div>
|
||||
<div class="control">
|
||||
<input datetimepicker="1" class="input datetimepicker-month" type="number">
|
||||
</div>
|
||||
<div class="control">
|
||||
<a id="ccn-calendar-calendar-btnJump" i18n-name="ccn-i18n-calendar-calendar-jump" class="button is-info"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-item control">
|
||||
<a id="ccn-calendar-calendar-btnJump" class="button" datetimepicker="1">
|
||||
<span id="ccn-calendar-calendar-textMonth"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="level-right">
|
||||
<div class="level-item control">
|
||||
<a id="ccn-calendar-calendar-btnNextMonth" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<nav class="level is-mobile">
|
||||
<div class="level-item control">
|
||||
<a id="ccn-calendar-calendar-btnToday" i18n-name="ccn-i18n-calendar-calendar-today" class="button is-info"></a>
|
||||
</div>
|
||||
<div class="level-item control">
|
||||
<a id="ccn-calendar-calendar-btnAdd" i18n-name="ccn-i18n-calendar-calendar-add" class="button is-primary"></a>
|
||||
</div>
|
||||
<div class="level-item control">
|
||||
<a id="ccn-calendar-calendar-btnNextMonth" class="button">
|
||||
<span class="icon is-small"><i class="fas fa-chevron-circle-right"></i></span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div id="ccn-calendar-calendarBody" class="card" style="padding: 1.25rem; display: flex; flex-flow: column;">
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title id="ccn-pageName"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title id="ccn-pageName"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
|
||||
@@ -73,38 +73,9 @@
|
||||
<section class="section">
|
||||
|
||||
<h2 class="subtitle" i18n-name="ccn-i18n-event-startDateTime"></h2>
|
||||
<div class="control-list">
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-year"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="1" class="input datetimepicker-year" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-month"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="1" class="input datetimepicker-month" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-day"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="1" class="input datetimepicker-day" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-hour"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="1" class="input datetimepicker-hour" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-minute"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="1" class="input datetimepicker-minute" type="number">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a id="ccn-event-btnStartDateTime" class="button" datetimepicker="1">
|
||||
<span id="ccn-event-btnStartDateTime-text"></span>
|
||||
</a>
|
||||
|
||||
<h2 class="subtitle" i18n-name="ccn-i18n-event-endDateTime"></h2>
|
||||
<div class="control-list">
|
||||
@@ -115,38 +86,9 @@
|
||||
<a id="ccn-event-btnFullDay" class="button is-link" i18n-name="ccn-i18n-event-btnFullDay"></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-list">
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-year"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="2" class="input datetimepicker-year" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-month"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="2" class="input datetimepicker-month" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-day"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="2" class="input datetimepicker-day" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-hour"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="2" class="input datetimepicker-hour" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-minute"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="2" class="input datetimepicker-minute" type="number">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a id="ccn-event-btnEndDateTime" class="button" datetimepicker="2">
|
||||
<span id="ccn-event-btnEndDateTime-text"></span>
|
||||
</a>
|
||||
|
||||
</section>
|
||||
|
||||
@@ -279,28 +221,9 @@
|
||||
</label>
|
||||
</div>
|
||||
<div id="ccn-event-boxLoopStopDateTime">
|
||||
<div class="field">
|
||||
<div class="control-list">
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-year"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="3" class="input datetimepicker-year" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-month"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="3" class="input datetimepicker-month" type="number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" i18n-name="ccn-i18n-universal-text-day"></label>
|
||||
<div class="control">
|
||||
<input datetimepicker="3" class="input datetimepicker-day" type="number">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a id="ccn-event-btnLoopStopDateTime" class="button" datetimepicker="3">
|
||||
<span id="ccn-event-btnLoopStopDateTime-text"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div id="ccn-event-boxLoopStopTimes">
|
||||
<div class="field">
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title id="ccn-pageName"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title id="ccn-pageName"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title id="ccn-pageName"></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.12.1/js/all.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.jsdelivr.net/npm/jquery@3.4.1/dist/jquery.js"></script>
|
||||
8
frontend/.editorconfig
Normal file
8
frontend/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
1
frontend/.gitattributes
vendored
Normal file
1
frontend/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
39
frontend/.gitignore
vendored
Normal file
39
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
10
frontend/.oxlintrc.json
Normal file
10
frontend/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
48
frontend/README.md
Normal file
48
frontend/README.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# coleaf-frontend
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
pnpm lint
|
||||
```
|
||||
1
frontend/env.d.ts
vendored
Normal file
1
frontend/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
23
frontend/eslint.config.ts
Normal file
23
frontend/eslint.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
)
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>coconut-leaf</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "coleaf-frontend",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.2.0",
|
||||
"axios": "1.14.0",
|
||||
"bulma": "0.9.1",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-oxlint": "~1.60.0",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.60.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
"vue-tsc": "^3.2.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
3584
frontend/pnpm-lock.yaml
generated
Normal file
3584
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
3
frontend/public/index.scss
Normal file
3
frontend/public/index.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
// 导入 Bulma
|
||||
@charset "utf-8";
|
||||
@import "bulma/bulma.sass";
|
||||
92
frontend/src/App.vue
Normal file
92
frontend/src/App.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useLanguageStore } from './stores/language';
|
||||
import { useTokenStore } from './stores/token';
|
||||
import MessageBox from '@/components/MessageBox.vue';
|
||||
import { logout as apiCommonLogout } from './api/common';
|
||||
import { goToHome } from '@/router';
|
||||
|
||||
const language = useLanguageStore();
|
||||
const token = useTokenStore();
|
||||
|
||||
const isBurgerActive = ref<boolean>(false);
|
||||
|
||||
const messagebox = ref<InstanceType<typeof MessageBox> | null>(null);
|
||||
|
||||
const logout = async () => {
|
||||
const tokenStore = useTokenStore();
|
||||
const rv = await apiCommonLogout(tokenStore.currentToken);
|
||||
if (rv) {
|
||||
// OK. We logged out.
|
||||
// Clear token.
|
||||
tokenStore.logout();
|
||||
// And go to Home page
|
||||
goToHome();
|
||||
} else {
|
||||
// Show logout error.
|
||||
messagebox.value?.show("Fail to logout due to unknow reason. Consider refreshing page to solve problem.");
|
||||
}
|
||||
}
|
||||
|
||||
// Process burger menu.
|
||||
// This is copied from Bulma website and modified for Vue.
|
||||
// Check for click events on the navbar burger icon
|
||||
const toggleBurger = () => {
|
||||
// Toggle the "is-active" class on both the "navbar-burger" and the "navbar-menu"
|
||||
isBurgerActive.value = !isBurgerActive
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="navbar has-shadow is-spaced bd-navbar" role="navigation" aria-label="main navigation">
|
||||
<div class="navbar-brand">
|
||||
<router-link class="navbar-item" to="/">
|
||||
<img src="/public/favicon.ico"><b style="margin:0 0 0 14px;">coconut-leaf</b>
|
||||
</router-link>
|
||||
|
||||
<a role="button" class="navbar-burger burger" :class="{ 'is-active': isBurgerActive }" @click="toggleBurger"
|
||||
aria-label="menu" aria-expanded="false" data-target="coleaf-navbar">
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
<span aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="coleaf-navbar" class="navbar-menu" :class="{ 'is-active': isBurgerActive }">
|
||||
<div class="navbar-start">
|
||||
<router-link class="navbar-item" to="/home">Home</router-link>
|
||||
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/collection">Collection</router-link>
|
||||
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/calendar">Calendar</router-link>
|
||||
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/todo">Todo</router-link>
|
||||
<router-link v-if="token.isLoggedIn" class="navbar-item" to="/admin">Admin</router-link>
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
<p class="navbar-item">
|
||||
<router-link v-if="!token.isLoggedIn" class="button is-primary" to="/login">Login</router-link>
|
||||
</p>
|
||||
<p class="navbar-item">
|
||||
<a v-if="token.isLoggedIn" class="button is-primary" @click="logout">Logout</a>
|
||||
</p>
|
||||
|
||||
<div class="navbar-item has-dropdown is-hoverable">
|
||||
<a v-if="language.isEnglish" class="navbar-link">English</a>
|
||||
<a v-else-if="language.isSimplifiedChinese" class="navbar-link">简体中文</a>
|
||||
|
||||
<div class="navbar-dropdown">
|
||||
<a @click="language.changeToEnglish()" class="navbar-item">English</a>
|
||||
<a @click="language.changeToSimplifiedChinese()" class="navbar-item">简体中文</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- The output result of router -->
|
||||
<router-view></router-view>
|
||||
|
||||
<MessageBox ref="messagebox" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
75
frontend/src/api/admin.ts
Normal file
75
frontend/src/api/admin.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// User interface
|
||||
interface User {
|
||||
username: string
|
||||
isAdmin: boolean
|
||||
// Add other user-related fields as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all users (admin only)
|
||||
* @param token - Authentication token
|
||||
* @returns Array of users or null if operation failed
|
||||
*/
|
||||
export async function get(token: string): Promise<User[] | undefined> {
|
||||
return apiWrapper('/api/admin/get', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new user (admin only)
|
||||
* @param token - Authentication token
|
||||
* @param username - Username
|
||||
* @returns Created user or null if operation failed
|
||||
*/
|
||||
export async function add(token: string, username: string): Promise<User | undefined> {
|
||||
return apiWrapper('/api/admin/add', { token, username });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user (admin only)
|
||||
* @param token - Authentication token
|
||||
* @param username - Username
|
||||
* @param params - Update parameters (partial)
|
||||
* @returns true if update successful, false otherwise
|
||||
*/
|
||||
export async function update(
|
||||
token: string,
|
||||
username: string,
|
||||
params: {
|
||||
password?: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const data: any = {
|
||||
token,
|
||||
username
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
if (typeof params.password !== 'undefined') {
|
||||
data.password = params.password;
|
||||
count++;
|
||||
}
|
||||
if (typeof params.isAdmin !== 'undefined') {
|
||||
data.isAdmin = params.isAdmin;
|
||||
count++;
|
||||
}
|
||||
|
||||
// If no update parameters provided, return true
|
||||
if (count === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return boolApiWrapper('/api/admin/update', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user (admin only)
|
||||
* @param token - Authentication token
|
||||
* @param username - Username
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteItem(token: string, username: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/admin/delete', { token, username });
|
||||
}
|
||||
141
frontend/src/api/calendar.ts
Normal file
141
frontend/src/api/calendar.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// Calendar event interface
|
||||
interface CalendarEvent {
|
||||
uuid: string
|
||||
belongTo: string
|
||||
title: string
|
||||
description: string
|
||||
eventDateTimeStart: string
|
||||
eventDateTimeEnd: string
|
||||
loopRules: string
|
||||
timezoneOffset: number
|
||||
lastChange: string
|
||||
}
|
||||
|
||||
// Description object interface
|
||||
interface DescriptionObject {
|
||||
description: string
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize calendar description
|
||||
* @param description - Description text
|
||||
* @param color - Color value
|
||||
* @returns JSON string
|
||||
*/
|
||||
export function serializeDescription(description: string, color: string): string {
|
||||
const sobj: DescriptionObject = {
|
||||
description,
|
||||
color
|
||||
}
|
||||
return JSON.stringify(sobj)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize calendar description
|
||||
* @param str - JSON string
|
||||
* @returns Description object
|
||||
*/
|
||||
export function deserializeDescription(str: string): DescriptionObject {
|
||||
try {
|
||||
return JSON.parse(str) as DescriptionObject
|
||||
} catch (err) {
|
||||
return {
|
||||
description: "",
|
||||
color: "#000000" // DefaultColor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full calendar events within date range
|
||||
* @param token - Authentication token
|
||||
* @param startDateTime - Start datetime
|
||||
* @param endDateTime - End datetime
|
||||
* @returns Array of calendar events or null if operation failed
|
||||
*/
|
||||
export async function getFull(token: string, startDateTime: string, endDateTime: string): Promise<CalendarEvent[] | undefined> {
|
||||
return apiWrapper('/api/calendar/getFull', { token, startDateTime, endDateTime });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calendar event detail by UUID
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Event UUID
|
||||
* @returns Calendar event or null if operation failed
|
||||
*/
|
||||
export async function getDetail(token: string, uuid: string): Promise<CalendarEvent | undefined> {
|
||||
return apiWrapper('/api/calendar/getDetail', { token, uuid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update calendar event
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Event UUID
|
||||
* @param params - Update parameters (partial)
|
||||
* @returns Updated calendar event or null if operation failed
|
||||
*/
|
||||
export async function update(
|
||||
token: string,
|
||||
uuid: string,
|
||||
params: {
|
||||
belongTo?: string
|
||||
title?: string
|
||||
description?: string
|
||||
eventDateTimeStart?: string
|
||||
eventDateTimeEnd?: string
|
||||
loopRules?: string
|
||||
timezoneOffset?: number
|
||||
lastChange: string
|
||||
}
|
||||
): Promise<CalendarEvent | undefined> {
|
||||
const data: any = {
|
||||
token,
|
||||
uuid,
|
||||
lastChange: params.lastChange
|
||||
};
|
||||
|
||||
if (params.belongTo !== undefined) data.belongTo = params.belongTo;
|
||||
if (params.title !== undefined) data.title = params.title;
|
||||
if (params.description !== undefined) data.description = params.description;
|
||||
if (params.eventDateTimeStart !== undefined) data.eventDateTimeStart = params.eventDateTimeStart;
|
||||
if (params.eventDateTimeEnd !== undefined) data.eventDateTimeEnd = params.eventDateTimeEnd;
|
||||
if (params.loopRules !== undefined) data.loopRules = params.loopRules;
|
||||
if (params.timezoneOffset !== undefined) data.timezoneOffset = params.timezoneOffset;
|
||||
|
||||
return apiWrapper('/api/calendar/update', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new calendar event
|
||||
* @param token - Authentication token
|
||||
* @param params - Event parameters
|
||||
* @returns Created calendar event or null if operation failed
|
||||
*/
|
||||
export async function add(
|
||||
token: string,
|
||||
params: {
|
||||
belongTo: string
|
||||
title: string
|
||||
description: string
|
||||
eventDateTimeStart: string
|
||||
eventDateTimeEnd: string
|
||||
loopRules: string
|
||||
timezoneOffset: number
|
||||
}
|
||||
): Promise<CalendarEvent | undefined> {
|
||||
return apiWrapper('/api/calendar/add', { token, ...params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete calendar event
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Event UUID
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteEvent(token: string, uuid: string, lastChange: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/calendar/delete', { token, uuid, lastChange });
|
||||
}
|
||||
126
frontend/src/api/collection.ts
Normal file
126
frontend/src/api/collection.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
type Uuid = string;
|
||||
|
||||
// Collection item interface
|
||||
interface CollectionItem {
|
||||
uuid: Uuid
|
||||
name: string
|
||||
lastChange: string
|
||||
}
|
||||
|
||||
// Sharing info interface
|
||||
interface SharingInfo {
|
||||
target: string
|
||||
// Add other sharing-related fields as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all owned collections
|
||||
* @param token - Authentication token
|
||||
* @returns Array of collection items or null if operation failed
|
||||
*/
|
||||
export async function getFullOwn(token: string): Promise<CollectionItem[] | undefined> {
|
||||
return apiWrapper('/api/collection/getFullOwn', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get owned collection detail by UUID
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @returns Collection item or null if operation failed
|
||||
*/
|
||||
export async function getDetailOwn(token: string, uuid: Uuid): Promise<CollectionItem | undefined> {
|
||||
return apiWrapper('/api/collection/getDetailOwn', { token, uuid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new owned collection
|
||||
* @param token - Authentication token
|
||||
* @param name - Collection name
|
||||
* @returns Created collection item or null if operation failed
|
||||
*/
|
||||
export async function addOwn(token: string, name: string): Promise<Uuid | undefined> {
|
||||
return apiWrapper('/api/collection/addOwn', { token, name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update owned collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @param name - New name
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns Updated collection item or null if operation failed
|
||||
*/
|
||||
export async function updateOwn(
|
||||
token: string,
|
||||
uuid: Uuid,
|
||||
name: string,
|
||||
lastChange: string
|
||||
): Promise<CollectionItem | undefined> {
|
||||
return apiWrapper('/api/collection/updateOwn', { token, uuid, name, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete owned collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteOwn(token: string, uuid: Uuid, lastChange: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/collection/deleteOwn', { token, uuid, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get sharing information for a collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @returns Array of sharing info or null if operation failed
|
||||
*/
|
||||
export async function getSharing(token: string, uuid: Uuid): Promise<SharingInfo[] | undefined> {
|
||||
return apiWrapper('/api/collection/getSharing', { token, uuid });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete sharing for a collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @param target - Target user
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns Result data or null if operation failed
|
||||
*/
|
||||
export async function deleteSharing(
|
||||
token: string,
|
||||
uuid: Uuid,
|
||||
target: string,
|
||||
lastChange: string
|
||||
): Promise<any | undefined> {
|
||||
return apiWrapper('/api/collection/deleteSharing', { token, uuid, target, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add sharing for a collection
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Collection UUID
|
||||
* @param target - Target user
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns Result data or null if operation failed
|
||||
*/
|
||||
export async function addSharing(
|
||||
token: string,
|
||||
uuid: Uuid,
|
||||
target: string,
|
||||
lastChange: string
|
||||
): Promise<any | undefined> {
|
||||
return apiWrapper('/api/collection/addSharing', { token, uuid, target, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all shared collections
|
||||
* @param token - Authentication token
|
||||
* @returns Array of collection items or null if operation failed
|
||||
*/
|
||||
export async function getShared(token: string): Promise<CollectionItem[] | undefined> {
|
||||
return apiWrapper('/api/collection/getShared', { token });
|
||||
}
|
||||
43
frontend/src/api/common.ts
Normal file
43
frontend/src/api/common.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// /**
|
||||
// * Login with salt
|
||||
// * @param username - Username
|
||||
// * @param password - Password
|
||||
// * @returns Token if login successful, undefined otherwise
|
||||
// */
|
||||
// export async function login(username: string, password: string): Promise<string | undefined> {
|
||||
// const salt: string | undefined = await apiWrapper('/api/common/salt', { username });
|
||||
// if (typeof salt === 'undefined') return undefined;
|
||||
|
||||
// const computedPassword = computePasswordWithSalt(password, salt);
|
||||
// return apiWrapper('/api/common/login', { username, password: computedPassword });
|
||||
// }
|
||||
|
||||
/**
|
||||
* Web login
|
||||
* @param username - Username
|
||||
* @param password - Password
|
||||
* @returns Token if login successful, undefined otherwise
|
||||
*/
|
||||
export async function webLogin(username: string, password: string): Promise<string | undefined> {
|
||||
return apiWrapper('/api/common/webLogin', { username, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
* @param token - Authentication token
|
||||
* @returns true if logout successful, false otherwise if logout failed
|
||||
*/
|
||||
export async function logout(token: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/common/logout', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token
|
||||
* @param token - Authentication token
|
||||
* @returns true if token is valid, false otherwise
|
||||
*/
|
||||
export async function tokenValid(token: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/common/tokenValid', { token });
|
||||
}
|
||||
57
frontend/src/api/index.ts
Normal file
57
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Response interface
|
||||
interface ApiResponse<T = any> {
|
||||
success: boolean,
|
||||
error: string,
|
||||
data: T,
|
||||
}
|
||||
|
||||
export async function apiWrapper<T>(url: string, data: Record<string, any>): Promise<T | undefined> {
|
||||
try {
|
||||
// 自动编码为 key=value&key2=value2 格式
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
params.append(key, String(value));
|
||||
});
|
||||
// 发起请求
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
mode: "cors",
|
||||
cache: "no-cache",
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
// 明确指定内容类型
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
redirect: "follow",
|
||||
referrerPolicy: "no-referrer",
|
||||
body: params.toString(),
|
||||
});
|
||||
|
||||
// 检查 HTTP 状态码 (fetch 只有在网络故障时才会 reject,HTTP 404/500 不会)
|
||||
if (!response.ok) {
|
||||
console.error(`HTTP failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// 解析 JSON body
|
||||
// 注意:response.json() 返回的是一个 Promise,所以需要 await
|
||||
const payload = await response.json() as ApiResponse<T>;
|
||||
|
||||
// 检查API返回结果
|
||||
if (payload.success) {
|
||||
return payload.data;
|
||||
} else {
|
||||
console.error(`API failed: ${payload.error}`);
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
// 统一错误处理
|
||||
console.error(`Fetch failed: ${error}`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function boolApiWrapper<U>(url: string, data: Record<string, any>): Promise<boolean> {
|
||||
const rv = await apiWrapper<null>(url, data);
|
||||
return rv !== undefined;
|
||||
}
|
||||
|
||||
45
frontend/src/api/profile.ts
Normal file
45
frontend/src/api/profile.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// Token info interface
|
||||
interface TokenInfo {
|
||||
token: string
|
||||
// Add other token-related fields as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user is admin
|
||||
* @param token - Authentication token
|
||||
* @returns true if user is admin, false otherwise
|
||||
*/
|
||||
export async function isAdmin(token: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/profile/isAdmin', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Change user password
|
||||
* @param token - Authentication token
|
||||
* @param password - New password
|
||||
* @returns true if change successful, false otherwise
|
||||
*/
|
||||
export async function changePassword(token: string, password: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/profile/changePassword', { token, password });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user tokens
|
||||
* @param token - Authentication token
|
||||
* @returns Array of token info or undefined if operation failed
|
||||
*/
|
||||
export async function getToken(token: string): Promise<TokenInfo[] | undefined> {
|
||||
return apiWrapper('/api/profile/getToken', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a token
|
||||
* @param token - Authentication token
|
||||
* @param deleteToken - Token to delete
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteToken(token: string, deleteToken: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/profile/deleteToken', { token, deleteToken });
|
||||
}
|
||||
56
frontend/src/api/todo.ts
Normal file
56
frontend/src/api/todo.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import axios from 'axios'
|
||||
import { apiWrapper, boolApiWrapper } from './index'
|
||||
|
||||
// Todo item interface
|
||||
interface TodoItem {
|
||||
uuid: string
|
||||
data: any
|
||||
lastChange: string
|
||||
// Add other todo-related fields as needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all todos
|
||||
* @param token - Authentication token
|
||||
* @returns Array of todo items or null if operation failed
|
||||
*/
|
||||
export async function getFull(token: string): Promise<TodoItem[] | undefined> {
|
||||
return apiWrapper('/api/todo/getFull', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new todo
|
||||
* @param token - Authentication token
|
||||
* @returns Created todo item or null if operation failed
|
||||
*/
|
||||
export async function add(token: string): Promise<TodoItem | undefined> {
|
||||
return apiWrapper('/api/todo/add', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update todo
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Todo UUID
|
||||
* @param data - Todo data
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns Updated todo item or null if operation failed
|
||||
*/
|
||||
export async function update(
|
||||
token: string,
|
||||
uuid: string,
|
||||
data: any,
|
||||
lastChange: string
|
||||
): Promise<TodoItem | undefined> {
|
||||
return apiWrapper('/api/todo/update', { token, uuid, data, lastChange });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete todo
|
||||
* @param token - Authentication token
|
||||
* @param uuid - Todo UUID
|
||||
* @param lastChange - Last change timestamp
|
||||
* @returns true if deletion successful, false otherwise
|
||||
*/
|
||||
export async function deleteTodo(token: string, uuid: string, lastChange: string): Promise<boolean> {
|
||||
return boolApiWrapper('/api/todo/delete', { token, uuid, lastChange });
|
||||
}
|
||||
41
frontend/src/components/MessageBox.vue
Normal file
41
frontend/src/components/MessageBox.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
const isVisible = ref(false);
|
||||
const title = ref<string>("");
|
||||
const content = ref<string>("");
|
||||
|
||||
const show = (_content: string, _title?: string) => {
|
||||
title.value = _title ?? "Notification";
|
||||
content.value = _content;
|
||||
isVisible.value = true;
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
isVisible.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal" :class="{ 'is-active': isVisible }" style="float: left; position: fixed; top: 0; bottom: 0; left: 0; right: 0;">
|
||||
<div class="modal-background"></div>
|
||||
<div class="modal-card">
|
||||
<header class="modal-card-head">
|
||||
<p class="modal-card-title">{{ title }}</p>
|
||||
<button class="delete" aria-label="close" @click="hide"></button>
|
||||
</header>
|
||||
<div class="modal-card-body">
|
||||
<p>{{ content }}</p>
|
||||
</div>
|
||||
<footer class="modal-card-foot">
|
||||
<button class="button is-success" @click="hide">OK</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
25
frontend/src/main.ts
Normal file
25
frontend/src/main.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import { library } from '@fortawesome/fontawesome-svg-core'
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
import { faUser, faLock } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import '../public/index.scss'
|
||||
|
||||
const pinia = createPinia();
|
||||
pinia.use(piniaPluginPersistedstate);
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
library.add(faUser, faLock);
|
||||
app.component('font-awesome-icon', FontAwesomeIcon);
|
||||
|
||||
app.mount('#app');
|
||||
55
frontend/src/router/index.ts
Normal file
55
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useTokenStore } from '@/stores/token'
|
||||
|
||||
import Home from '@/views/Home.vue'
|
||||
import Collection from '@/views/Collection.vue'
|
||||
import Calendar from '@/views/Calendar.vue'
|
||||
import CalendarEvent from '@/views/CalendarEvent.vue'
|
||||
import Todo from '@/views/Todo.vue'
|
||||
import Admin from '@/views/Admin.vue'
|
||||
import Login from '@/views/Login.vue'
|
||||
|
||||
import NotFound from '@/views/NotFound.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/home', name: "Home", component: Home },
|
||||
{ path: '/collection', name: "Collection", meta: { requireLoggedInCheck: true }, component: Collection },
|
||||
{ path: '/calendar', name: "Calendar", meta: { requireLoggedInCheck: true }, component: Calendar },
|
||||
{ path: '/todo', name: "Todo", meta: { requireLoggedInCheck: true }, component: Todo },
|
||||
{ path: '/admin', name: "Admin", meta: { requireLoggedInCheck: true }, component: Admin },
|
||||
|
||||
{ path: '/calendar/event', name: "CalendarEvent", meta: { requireLoggedInCheck: true }, component: CalendarEvent },
|
||||
{ path: '/login', name: "Collection", meta: { requireLoggedOutCheck: true }, component: Login },
|
||||
|
||||
{ path: '/404', name: "NotFound", component: NotFound },
|
||||
|
||||
{ path: '/', name: "Default", redirect: '/home' },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/404' },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: routes,
|
||||
});
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
// Only check for those flagged.
|
||||
const token = useTokenStore();
|
||||
if (to.meta.requireLoggedInCheck) {
|
||||
if (!token.isLoggedIn) {
|
||||
return { name: 'Default', replace: true };
|
||||
}
|
||||
} else if (to.meta.requireLoggedOutCheck) {
|
||||
if (token.isLoggedIn) {
|
||||
return { name: 'Default', replace: true };
|
||||
}
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
|
||||
export const goToHome = () => {
|
||||
router.push({ name: 'Home' })
|
||||
}
|
||||
|
||||
export default router
|
||||
35
frontend/src/stores/language.ts
Normal file
35
frontend/src/stores/language.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { Language } from '@/utils/i18n'
|
||||
|
||||
interface LanguageState {
|
||||
language: Language
|
||||
}
|
||||
|
||||
export const useLanguageStore = defineStore('language', {
|
||||
state: (): LanguageState => ({
|
||||
language: Language.English
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isEnglish: (state) => state.language === Language.English,
|
||||
isSimplifiedChinese: (state) => state.language === Language.SimplifiedChinese,
|
||||
},
|
||||
|
||||
actions: {
|
||||
changeLanguage(lang: Language) {
|
||||
this.language = lang;
|
||||
},
|
||||
changeToEnglish() {
|
||||
this.changeLanguage(Language.English);
|
||||
},
|
||||
changeToSimplifiedChinese() {
|
||||
this.changeLanguage(Language.SimplifiedChinese);
|
||||
},
|
||||
},
|
||||
|
||||
persist: {
|
||||
key: 'ccn-i18n',
|
||||
storage: localStorage,
|
||||
pick: ['language'],
|
||||
},
|
||||
})
|
||||
31
frontend/src/stores/token.ts
Normal file
31
frontend/src/stores/token.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
interface TokenState {
|
||||
token: string | null
|
||||
}
|
||||
|
||||
export const useTokenStore = defineStore('token', {
|
||||
state: (): TokenState => ({
|
||||
token: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isLoggedIn: (state) => typeof state.token === 'string',
|
||||
currentToken: (state) => state.token as string,
|
||||
},
|
||||
|
||||
actions: {
|
||||
login(token: string) {
|
||||
this.token = token;
|
||||
},
|
||||
logout() {
|
||||
this.token = null;
|
||||
},
|
||||
},
|
||||
|
||||
persist: {
|
||||
key: 'ccn-token',
|
||||
storage: localStorage,
|
||||
pick: ['token'],
|
||||
},
|
||||
})
|
||||
4
frontend/src/utils/i18n.ts
Normal file
4
frontend/src/utils/i18n.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum Language {
|
||||
English,
|
||||
SimplifiedChinese,
|
||||
}
|
||||
8
frontend/src/views/Admin.vue
Normal file
8
frontend/src/views/Admin.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<h1>Congratulations</h1>
|
||||
<p>This is admin.</p>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
8
frontend/src/views/Calendar.vue
Normal file
8
frontend/src/views/Calendar.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<h1>Congratulations</h1>
|
||||
<p>This is calendar.</p>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
8
frontend/src/views/CalendarEvent.vue
Normal file
8
frontend/src/views/CalendarEvent.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<h1>Congratulations</h1>
|
||||
<p>This is calendar event.</p>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user