Compare commits
No commits in common. "master" and "archive-flask" have entirely different histories.
master
...
archive-fl
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
venv/
|
||||
*__pycache__/
|
||||
.st*
|
||||
22
README.md
Normal file
@ -0,0 +1,22 @@
|
||||
<h1 align="center">🎋 Tanabata File Manager 🎋</h1>
|
||||
|
||||
---
|
||||
|
||||
<!-- [![Release version][release-shield]][release-link] -->
|
||||
|
||||
## Contents
|
||||
|
||||
- [About](#about)
|
||||
|
||||
## About
|
||||
|
||||
Tanabata (_jp._ 七夕) is Japanese festival. People generally celebrate this day (July 7th) by writing wishes, sometimes in the form of poetry, on _tanzaku_ (_jp._ 短冊), small pieces of paper, and hanging them on _sasa_ (_jp._ 笹), bamboo. See [this Wikipedia page](https://en.wikipedia.org/wiki/Tanabata) for more information.
|
||||
|
||||
Tanabata File Manager is a software project that will let you enjoy the Tanabata festival. It allows you to store and organize your files as _sasa_ bamboos, on which you can hang almost any number of _tanzaku_, just like adding tags on it.
|
||||
|
||||
---
|
||||
|
||||
<h6 align="center"><i>© Masahiko AMANO aka H1K0, 2022—present</i></h6>
|
||||
|
||||
<!-- [release-shield]: https://img.shields.io/github/release/H1K0/tanabata/all.svg?style=for-the-badge
|
||||
[release-link]: https://github.com/H1K0/tanabata/releases -->
|
||||
5
_config.yml
Normal file
@ -0,0 +1,5 @@
|
||||
title: Tanabata FM
|
||||
description: Web file manager with tags!
|
||||
remote_theme: pages-themes/merlot@v0.2.0
|
||||
plugins:
|
||||
- jekyll-remote-theme
|
||||
374
api/tfm_api.py
Normal file
@ -0,0 +1,374 @@
|
||||
from configparser import ConfigParser
|
||||
from psycopg2.pool import ThreadedConnectionPool
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from contextlib import contextmanager
|
||||
from os import access, W_OK, makedirs, chmod, system
|
||||
from os.path import isfile, join, basename
|
||||
from shutil import move
|
||||
from magic import Magic
|
||||
from preview_generator.manager import PreviewManager
|
||||
|
||||
conf = None
|
||||
|
||||
mage = None
|
||||
previewer = None
|
||||
|
||||
db_pool = None
|
||||
|
||||
DEFAULT_SORTING = {
|
||||
"files": {
|
||||
"key": "created",
|
||||
"asc": False
|
||||
},
|
||||
"tags": {
|
||||
"key": "created",
|
||||
"asc": False
|
||||
},
|
||||
"categories": {
|
||||
"key": "created",
|
||||
"asc": False
|
||||
},
|
||||
"pools": {
|
||||
"key": "created",
|
||||
"asc": False
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def Initialize(conf_path="/etc/tfm/tfm.conf"):
|
||||
global mage, previewer
|
||||
load_config(conf_path)
|
||||
mage = Magic(mime=True)
|
||||
previewer = PreviewManager(conf["Paths"]["Thumbs"])
|
||||
db_connect(conf["DB.limits"]["MinimumConnections"], conf["DB.limits"]["MaximumConnections"], **conf["DB.params"])
|
||||
|
||||
|
||||
def load_config(path):
|
||||
global conf
|
||||
conf = ConfigParser()
|
||||
conf.read(path)
|
||||
|
||||
|
||||
def db_connect(minconn, maxconn, **kwargs):
|
||||
global db_pool
|
||||
db_pool = ThreadedConnectionPool(minconn, maxconn, **kwargs)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _db_cursor():
|
||||
global db_pool
|
||||
try:
|
||||
conn = db_pool.getconn()
|
||||
except:
|
||||
raise RuntimeError("Database not connected")
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
yield cur
|
||||
conn.commit()
|
||||
except:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
db_pool.putconn(conn)
|
||||
|
||||
|
||||
def _validate_column_name(cur, table, column):
|
||||
cur.execute("SELECT get_column_names(%s) AS name", (table,))
|
||||
if all([column!=col["name"] for col in cur.fetchall()]):
|
||||
raise RuntimeError("Invalid column name")
|
||||
|
||||
|
||||
def authorize(username, password, useragent):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_session_request(tfm_user_auth(%s, %s), %s) AS sid", (username, password, useragent))
|
||||
sid = cur.fetchone()["sid"]
|
||||
return TSession(sid)
|
||||
|
||||
|
||||
class TSession:
|
||||
sid = None
|
||||
|
||||
def __init__(self, sid):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_session_validate(%s) IS NOT NULL AS valid", (sid,))
|
||||
if not cur.fetchone()["valid"]:
|
||||
raise RuntimeError("Invalid sid")
|
||||
self.sid = sid
|
||||
|
||||
def terminate(self):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_session_terminate(%s)", (self.sid,))
|
||||
del self
|
||||
|
||||
@property
|
||||
def username(self):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_session_username(%s) AS name", (self.sid,))
|
||||
return cur.fetchone()["name"]
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT * FROM tfm_user_get_info(%s)", (self.sid,))
|
||||
return cur.fetchone()["can_edit"]
|
||||
|
||||
def get_files(self, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_files", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_files(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid,))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_files_by_filter(self, philter=None, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_files", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_files_by_filter(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid, philter))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_tags(self, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_tags", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_tags(%%s) ORDER BY %s %s, name ASC OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid,))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_categories(self, order_key=DEFAULT_SORTING["categories"]["key"], order_asc=DEFAULT_SORTING["categories"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_categories", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_categories(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid,))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_pools(self, order_key=DEFAULT_SORTING["pools"]["key"], order_asc=DEFAULT_SORTING["pools"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_pools", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_pools(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid,))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_autotags(self, order_key="child_id", order_asc=True, offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_autotags", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_autotags(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid,))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_my_sessions(self, order_key="started", order_asc=False, offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_sessions", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_my_sessions(%%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid,))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_tags_by_file(self, file_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_tags", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_tags_by_file(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid, file_id))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_files_by_tag(self, tag_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_files", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_files_by_tag(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid, tag_id))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_files_by_pool(self, pool_id, order_key=DEFAULT_SORTING["files"]["key"], order_asc=DEFAULT_SORTING["files"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_files", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_files_by_pool(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid, pool_id))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_parent_tags(self, tag_id, order_key=DEFAULT_SORTING["tags"]["key"], order_asc=DEFAULT_SORTING["tags"]["asc"], offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_tags", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_parent_tags(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid, tag_id))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_my_file_views(self, file_id=None, order_key="datetime", order_asc=False, offset=0, limit=None):
|
||||
with _db_cursor() as cur:
|
||||
_validate_column_name(cur, "v_files", order_key)
|
||||
cur.execute("SELECT * FROM tfm_get_my_file_views(%%s, %%s) ORDER BY %s %s OFFSET %s LIMIT %s" % (
|
||||
order_key,
|
||||
"ASC" if order_asc else "DESC",
|
||||
int(offset),
|
||||
int(limit) if limit is not None else "ALL"
|
||||
), (self.sid, file_id))
|
||||
return list(map(dict, cur.fetchall()))
|
||||
|
||||
def get_file(self, file_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT * FROM tfm_get_files(%s) WHERE id=%s", (self.sid, file_id))
|
||||
return cur.fetchone()
|
||||
|
||||
def get_tag(self, tag_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT * FROM tfm_get_tags(%s) WHERE id=%s", (self.sid, tag_id))
|
||||
return cur.fetchone()
|
||||
|
||||
def get_category(self, category_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT * FROM tfm_get_categories(%s) WHERE id=%s", (self.sid, category_id))
|
||||
return cur.fetchone()
|
||||
|
||||
def view_file(self, file_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_view_file(%s, %s)", (self.sid, file_id))
|
||||
|
||||
def add_file(self, path, datetime=None, notes=None, is_private=None, orig_name=True):
|
||||
if not isfile(path):
|
||||
raise FileNotFoundError("No such file '%s'" % path)
|
||||
if not access(conf["Paths"]["Files"], W_OK) or not access(conf["Paths"]["Thumbs"], W_OK):
|
||||
raise PermissionError("Invalid directories for files and thumbs")
|
||||
mime = mage.from_file(path)
|
||||
if orig_name == True:
|
||||
orig_name = basename(path)
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT * FROM tfm_add_file(%s, %s, %s, %s, %s, %s)", (self.sid, mime, datetime, notes, is_private, orig_name))
|
||||
res = cur.fetchone()
|
||||
file_id = res["f_id"]
|
||||
ext = res["ext"]
|
||||
file_path = join(conf["Paths"]["Files"], file_id)
|
||||
move(path, file_path)
|
||||
thumb_path = previewer.get_jpeg_preview(file_path, height=160, width=160)
|
||||
preview_path = previewer.get_jpeg_preview(file_path, height=1080, width=1920)
|
||||
chmod(file_path, 0o664)
|
||||
chmod(thumb_path, 0o664)
|
||||
chmod(preview_path, 0o664)
|
||||
return file_id, ext
|
||||
|
||||
def add_tag(self, name, notes=None, color=None, category_id=None, is_private=None):
|
||||
if color is not None:
|
||||
color = color.replace('#', '')
|
||||
if not category_id:
|
||||
category_id = None
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_add_tag(%s, %s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, category_id, is_private))
|
||||
return cur.fetchone()["id"]
|
||||
|
||||
def add_category(self, name, notes=None, color=None, is_private=None):
|
||||
if color is not None:
|
||||
color = color.replace('#', '')
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_add_category(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, color, is_private))
|
||||
return cur.fetchone()["id"]
|
||||
|
||||
def add_pool(self, name, notes=None, parent_id=None, is_private=None):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_add_pool(%s, %s, %s, %s, %s) AS id", (self.sid, name, notes, parent_id, is_private))
|
||||
return cur.fetchone()["id"]
|
||||
|
||||
def add_autotag(self, child_id, parent_id, is_active=None, apply_to_existing=None):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_add_autotag(%s, %s, %s, %s, %s) AS added", (self.sid, child_id, parent_id, is_active, apply_to_existing))
|
||||
return cur.fetchone()["added"]
|
||||
|
||||
def add_file_to_tag(self, file_id, tag_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_add_file_to_tag(%s, %s, %s) AS id", (self.sid, file_id, tag_id))
|
||||
return list(map(lambda t: t["id"], cur.fetchall()))
|
||||
|
||||
def add_file_to_pool(self, file_id, pool_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("SELECT tfm_add_file_to_pool(%s, %s, %s) AS added", (self.sid, file_id, pool_id))
|
||||
return cur.fetchone()["added"]
|
||||
|
||||
def edit_file(self, file_id, mime=None, datetime=None, notes=None, is_private=None):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_edit_file(%s, %s, %s, %s, %s, %s)", (self.sid, file_id, mime, datetime, notes, is_private))
|
||||
|
||||
def edit_tag(self, tag_id, name=None, notes=None, color=None, category_id=None, is_private=None):
|
||||
if color is not None:
|
||||
color = color.replace('#', '')
|
||||
if not category_id:
|
||||
category_id = None
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_edit_tag(%s, %s, %s, %s, %s, %s, %s)", (self.sid, tag_id, name, notes, color, category_id, is_private))
|
||||
|
||||
def edit_category(self, category_id, name=None, notes=None, color=None, is_private=None):
|
||||
if color is not None:
|
||||
color = color.replace('#', '')
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_edit_category(%s, %s, %s, %s, %s, %s)", (self.sid, category_id, name, notes, color, is_private))
|
||||
|
||||
def edit_pool(self, pool_id, name=None, notes=None, parent_id=None, is_private=None):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_edit_pool(%s, %s, %s, %s, %s, %s)", (self.sid, pool_id, name, notes, parent_id, is_private))
|
||||
|
||||
def remove_file(self, file_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_remove_file(%s, %s)", (self.sid, file_id))
|
||||
if system("rm %s/%s*" % (conf["Paths"]["Files"], file_id)):
|
||||
raise RuntimeError("Failed to remove file '%s'" % file_id)
|
||||
|
||||
def remove_tag(self, tag_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_remove_tag(%s, %s)", (self.sid, tag_id))
|
||||
|
||||
def remove_category(self, category_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_remove_category(%s, %s)", (self.sid, category_id))
|
||||
|
||||
def remove_pool(self, pool_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_remove_pool(%s, %s)", (self.sid, pool_id))
|
||||
|
||||
def remove_autotag(self, child_id, parent_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_remove_autotag(%s, %s, %s)", (self.sid, child_id, parent_id))
|
||||
|
||||
def remove_file_to_tag(self, file_id, tag_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_remove_file_to_tag(%s, %s, %s)", (self.sid, file_id, tag_id))
|
||||
|
||||
def remove_file_to_pool(self, file_id, pool_id):
|
||||
with _db_cursor() as cur:
|
||||
cur.execute("CALL tfm_remove_file_to_pool(%s, %s, %s)", (self.sid, file_id, pool_id))
|
||||
@ -1,19 +0,0 @@
|
||||
module tanabata
|
||||
|
||||
go 1.23.0
|
||||
|
||||
toolchain go1.23.10
|
||||
|
||||
require github.com/jackc/pgx/v5 v5.7.5
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx v3.6.2+incompatible // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
@ -1,32 +0,0 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o=
|
||||
github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@ -1,119 +0,0 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Name string `json:"name"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
CanCreate bool `json:"canCreate"`
|
||||
}
|
||||
|
||||
type MIME struct {
|
||||
Name string `json:"name"`
|
||||
Extension string `json:"extension"`
|
||||
}
|
||||
|
||||
type (
|
||||
CategoryCore struct {
|
||||
ID *string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
CategoryItem struct {
|
||||
CategoryCore
|
||||
}
|
||||
CategoryFull struct {
|
||||
CategoryCore
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Creator User `json:"creator"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
FileCore struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
MIME MIME `json:"mime"`
|
||||
}
|
||||
FileItem struct {
|
||||
FileCore
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Creator User `json:"creator"`
|
||||
}
|
||||
FileFull struct {
|
||||
FileCore
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Creator User `json:"creator"`
|
||||
Notes *string `json:"notes"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
Viewed int `json:"viewed"`
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
TagCore struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
TagItem struct {
|
||||
TagCore
|
||||
Category CategoryCore `json:"category"`
|
||||
}
|
||||
TagFull struct {
|
||||
TagCore
|
||||
Category CategoryCore `json:"category"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Creator User `json:"creator"`
|
||||
Notes *string `json:"notes"`
|
||||
UsedIncl int `json:"usedIncl"`
|
||||
UsedExcl int `json:"usedExcl"`
|
||||
}
|
||||
)
|
||||
|
||||
type Autotag struct {
|
||||
TriggerTag TagCore `json:"triggerTag"`
|
||||
AddTag TagCore `json:"addTag"`
|
||||
IsActive bool `json:"isActive"`
|
||||
}
|
||||
|
||||
type (
|
||||
PoolCore struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
PoolItem struct {
|
||||
PoolCore
|
||||
}
|
||||
PoolFull struct {
|
||||
PoolCore
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Creator User `json:"creator"`
|
||||
Notes *string `json:"notes"`
|
||||
Viewed int `json:"viewed"`
|
||||
}
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
ID int `json:"id"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
LastActivity time.Time `json:"lastActivity"`
|
||||
}
|
||||
|
||||
type Pagination struct {
|
||||
Total int `json:"total"`
|
||||
Offset int `json:"offset"`
|
||||
Limit int `json:"limit"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type Slice[T any] struct {
|
||||
Pagination Pagination `json:"pagination"`
|
||||
Data []T `json:"data"`
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
package domain
|
||||
|
||||
import "fmt"
|
||||
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
// File errors
|
||||
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
|
||||
ErrCodeMIMENotSupported ErrorCode = "MIME_NOT_SUPPORTED"
|
||||
|
||||
// Tag errors
|
||||
ErrCodeTagNotFound ErrorCode = "TAG_NOT_FOUND"
|
||||
|
||||
// General errors
|
||||
ErrCodeBadRequest ErrorCode = "BAD_REQUEST"
|
||||
ErrCodeInternal ErrorCode = "INTERNAL_SERVER_ERROR"
|
||||
)
|
||||
|
||||
type DomainError struct {
|
||||
Err error `json:"-"`
|
||||
Code ErrorCode `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Details []any `json:"-"`
|
||||
}
|
||||
|
||||
func (e *DomainError) Wrap(err error) *DomainError {
|
||||
e.Err = err
|
||||
return e
|
||||
}
|
||||
|
||||
func NewErrorFileNotFound(file_id string) *DomainError {
|
||||
return &DomainError{
|
||||
Code: ErrCodeFileNotFound,
|
||||
Message: fmt.Sprintf("File not found: %q", file_id),
|
||||
}
|
||||
}
|
||||
|
||||
func NewErrorMIMENotSupported(mime string) *DomainError {
|
||||
return &DomainError{
|
||||
Code: ErrCodeMIMENotSupported,
|
||||
Message: fmt.Sprintf("MIME not supported: %q", mime),
|
||||
}
|
||||
}
|
||||
|
||||
func NewErrorTagNotFound(tag_id string) *DomainError {
|
||||
return &DomainError{
|
||||
Code: ErrCodeTagNotFound,
|
||||
Message: fmt.Sprintf("Tag not found: %q", tag_id),
|
||||
}
|
||||
}
|
||||
|
||||
func NewErrorBadRequest(message string) *DomainError {
|
||||
return &DomainError{
|
||||
Code: ErrCodeBadRequest,
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func NewErrorUnexpected() *DomainError {
|
||||
return &DomainError{
|
||||
Code: ErrCodeInternal,
|
||||
Message: "An unexpected error occured",
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FileRepository interface {
|
||||
GetAccess(ctx context.Context, user_id int, file_id string) (canView, canEdit bool, domainErr *DomainError)
|
||||
GetSlice(ctx context.Context, user_id int, filter, sort string, limit, offset int) (files Slice[FileItem], domainErr *DomainError)
|
||||
Get(ctx context.Context, user_id int, file_id string) (file FileFull, domainErr *DomainError)
|
||||
Add(ctx context.Context, user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file FileCore, domainErr *DomainError)
|
||||
Update(ctx context.Context, file_id string, updates map[string]interface{}) (domainErr *DomainError)
|
||||
Delete(ctx context.Context, file_id string) (domainErr *DomainError)
|
||||
GetTags(ctx context.Context, user_id int, file_id string) (tags []TagItem, domainErr *DomainError)
|
||||
}
|
||||
@ -1,54 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"tanabata/internal/domain"
|
||||
)
|
||||
|
||||
// Initialize PostgreSQL database driver
|
||||
func New(dbURL string) (*pgxpool.Pool, error) {
|
||||
poolConfig, err := pgxpool.ParseConfig(dbURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse connection string: %w", err)
|
||||
}
|
||||
|
||||
poolConfig.MaxConns = 100
|
||||
poolConfig.MinConns = 0
|
||||
poolConfig.MaxConnLifetime = time.Hour
|
||||
poolConfig.HealthCheckPeriod = 30 * time.Second
|
||||
|
||||
ctx := context.Background()
|
||||
db, err := pgxpool.NewWithConfig(ctx, poolConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize DB connections pool: %w", err)
|
||||
}
|
||||
if err = db.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Transaction wrapper
|
||||
func transaction(ctx context.Context, db *pgxpool.Pool, handler func(context.Context, pgx.Tx) *domain.DomainError) (domainErr *domain.DomainError) {
|
||||
tx, err := db.Begin(ctx)
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
domainErr = handler(ctx, tx)
|
||||
if domainErr != nil {
|
||||
tx.Rollback(ctx)
|
||||
return
|
||||
}
|
||||
err = tx.Commit(ctx)
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -1,331 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"tanabata/internal/domain"
|
||||
)
|
||||
|
||||
type FileRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewFileRepository(db *pgxpool.Pool) *FileRepository {
|
||||
return &FileRepository{db: db}
|
||||
}
|
||||
|
||||
// Get user permissions on file
|
||||
func (s *FileRepository) GetAccess(ctx context.Context, user_id int, file_id string) (canView, canEdit bool, domainErr *domain.DomainError) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE),
|
||||
COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE)
|
||||
FROM data.files f
|
||||
LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1
|
||||
LEFT JOIN system.users u ON u.id=$1
|
||||
WHERE f.id=$2
|
||||
`, user_id, file_id)
|
||||
err := row.Scan(&canView, &canEdit)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
||||
return
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "22P02":
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get a set of files
|
||||
func (s *FileRepository) GetSlice(ctx context.Context, user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], domainErr *domain.DomainError) {
|
||||
filterCond, err := filterToSQL(filter)
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid filter string: %q", filter)).Wrap(err)
|
||||
return
|
||||
}
|
||||
sortExpr, err := sortToSQL(sort)
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid sorting parameter: %q", sort)).Wrap(err)
|
||||
return
|
||||
}
|
||||
// prepare query
|
||||
query := `
|
||||
SELECT
|
||||
f.id,
|
||||
f.name,
|
||||
m.name,
|
||||
m.extension,
|
||||
uuid_extract_timestamp(f.id),
|
||||
u.name,
|
||||
u.is_admin
|
||||
FROM data.files f
|
||||
JOIN system.mime m ON m.id=f.mime_id
|
||||
JOIN system.users u ON u.id=f.creator_id
|
||||
WHERE f.is_deleted IS FALSE AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND
|
||||
`
|
||||
query += filterCond
|
||||
queryCount := query
|
||||
query += sortExpr
|
||||
if limit >= 0 {
|
||||
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||
}
|
||||
if offset > 0 {
|
||||
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||
}
|
||||
// execute query
|
||||
domainErr = transaction(ctx, s.db, func(ctx context.Context, tx pgx.Tx) (domainErr *domain.DomainError) {
|
||||
rows, err := tx.Query(ctx, query, user_id)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "42P10":
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid sorting field: %q", sort[1:])).Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var file domain.FileItem
|
||||
err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin)
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
files.Data = append(files.Data, file)
|
||||
count++
|
||||
}
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
files.Pagination.Limit = limit
|
||||
files.Pagination.Offset = offset
|
||||
files.Pagination.Count = count
|
||||
row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id)
|
||||
err = row.Scan(&files.Pagination.Total)
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
}
|
||||
return
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get file
|
||||
func (s *FileRepository) Get(ctx context.Context, user_id int, file_id string) (file domain.FileFull, domainErr *domain.DomainError) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
f.id,
|
||||
f.name,
|
||||
m.name,
|
||||
m.extension,
|
||||
uuid_extract_timestamp(f.id),
|
||||
u.name,
|
||||
u.is_admin,
|
||||
f.notes,
|
||||
f.metadata,
|
||||
(SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1)
|
||||
FROM data.files f
|
||||
JOIN system.mime m ON m.id=f.mime_id
|
||||
JOIN system.users u ON u.id=f.creator_id
|
||||
WHERE f.is_deleted IS FALSE
|
||||
`, user_id, file_id)
|
||||
err := row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
||||
return
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "22P02":
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Add file
|
||||
func (s *FileRepository) Add(ctx context.Context, user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, domainErr *domain.DomainError) {
|
||||
var mime_id int
|
||||
var extension string
|
||||
row := s.db.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime)
|
||||
err := row.Scan(&mime_id, &extension)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
domainErr = domain.NewErrorMIMENotSupported(mime).Wrap(err)
|
||||
return
|
||||
}
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
row = s.db.QueryRow(ctx, `
|
||||
INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata)
|
||||
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6)
|
||||
RETURNING id
|
||||
`, name, mime_id, datetime, user_id, notes, metadata)
|
||||
err = row.Scan(&file.ID)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "22007":
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid datetime: %q", datetime)).Wrap(err)
|
||||
return
|
||||
case "23502":
|
||||
domainErr = domain.NewErrorBadRequest("Unable to set NULL to some fields").Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
file.Name = &name
|
||||
file.MIME.Name = mime
|
||||
file.MIME.Extension = extension
|
||||
return
|
||||
}
|
||||
|
||||
// Update file
|
||||
func (s *FileRepository) Update(ctx context.Context, file_id string, updates map[string]interface{}) (domainErr *domain.DomainError) {
|
||||
if len(updates) == 0 {
|
||||
// domainErr = domain.NewErrorBadRequest(nil, "No fields provided for update")
|
||||
return
|
||||
}
|
||||
query := "UPDATE data.files SET"
|
||||
newValues := []interface{}{file_id}
|
||||
count := 2
|
||||
for field, value := range updates {
|
||||
switch field {
|
||||
case "name", "notes":
|
||||
query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count)
|
||||
case "datetime":
|
||||
query += fmt.Sprintf(" %s=NULLIF($%d, '')::timestamptz", field, count)
|
||||
case "metadata":
|
||||
query += fmt.Sprintf(" %s=NULLIF($%d, '')::jsonb", field, count)
|
||||
default:
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Unknown field: %q", field))
|
||||
return
|
||||
}
|
||||
newValues = append(newValues, value)
|
||||
count++
|
||||
}
|
||||
query += fmt.Sprintf(" WHERE id=$1 AND is_deleted IS FALSE")
|
||||
commandTag, err := s.db.Exec(ctx, query, newValues...)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "22P02":
|
||||
domainErr = domain.NewErrorBadRequest("Invalid format of some values").Wrap(err)
|
||||
return
|
||||
case "22007":
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid datetime: %q", updates["datetime"])).Wrap(err)
|
||||
return
|
||||
case "23502":
|
||||
domainErr = domain.NewErrorBadRequest("Some fields cannot be empty").Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Delete file
|
||||
func (s *FileRepository) Delete(ctx context.Context, file_id string) (domainErr *domain.DomainError) {
|
||||
commandTag, err := s.db.Exec(ctx,
|
||||
"UPDATE data.files SET is_deleted=true WHERE id=$1 AND is_deleted IS FALSE",
|
||||
file_id)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "22P02":
|
||||
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Get list of tags of file
|
||||
func (s *FileRepository) GetTags(ctx context.Context, user_id int, file_id string) (tags []domain.TagItem, domainErr *domain.DomainError) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
t.id,
|
||||
t.name,
|
||||
t.color,
|
||||
c.id,
|
||||
c.name,
|
||||
c.color
|
||||
FROM data.tags t
|
||||
LEFT JOIN data.categories c ON c.id=t.category_id
|
||||
JOIN data.file_tag ft ON ft.tag_id=t.id AND ft.file_id=$2
|
||||
JOIN data.files f ON f.id=$2
|
||||
WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))
|
||||
`, user_id, file_id)
|
||||
if err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && (pgErr.Code == "22P02" || pgErr.Code == "22007") {
|
||||
domainErr = domain.NewErrorBadRequest(pgErr.Message).Wrap(err)
|
||||
return
|
||||
}
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var tag domain.TagItem
|
||||
err = rows.Scan(&tag.ID, &tag.Name, &tag.Color, &tag.Category.ID, &tag.Category.Name, &tag.Category.Color)
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
return
|
||||
}
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
domainErr = domain.NewErrorUnexpected().Wrap(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// Handle database error
|
||||
func handleDBError(errIn error) (statusCode int, err error) {
|
||||
if errIn == nil {
|
||||
statusCode = http.StatusOK
|
||||
return
|
||||
}
|
||||
if errors.Is(errIn, pgx.ErrNoRows) {
|
||||
err = fmt.Errorf("not found")
|
||||
statusCode = http.StatusNotFound
|
||||
return
|
||||
}
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(errIn, &pgErr) {
|
||||
switch pgErr.Code {
|
||||
case "22P02", "22007": // Invalid data format
|
||||
err = fmt.Errorf("%s", pgErr.Message)
|
||||
statusCode = http.StatusBadRequest
|
||||
return
|
||||
case "23505": // Unique constraint violation
|
||||
err = fmt.Errorf("already exists")
|
||||
statusCode = http.StatusConflict
|
||||
return
|
||||
}
|
||||
}
|
||||
return http.StatusInternalServerError, errIn
|
||||
}
|
||||
|
||||
// Convert "filter" URL param to SQL "WHERE" condition
|
||||
func filterToSQL(filter string) (sql string, err error) {
|
||||
// filterTokens := strings.Split(string(filter), ";")
|
||||
sql = "(true)"
|
||||
return
|
||||
}
|
||||
|
||||
// Convert "sort" URL param to SQL "ORDER BY"
|
||||
func sortToSQL(sort string) (sql string, err error) {
|
||||
if sort == "" {
|
||||
return
|
||||
}
|
||||
sortOptions := strings.Split(sort, ",")
|
||||
sql = " ORDER BY "
|
||||
for i, sortOption := range sortOptions {
|
||||
sortOrder := sortOption[:1]
|
||||
sortColumn := sortOption[1:]
|
||||
// parse sorting order marker
|
||||
switch sortOrder {
|
||||
case "+":
|
||||
sortOrder = "ASC"
|
||||
case "-":
|
||||
sortOrder = "DESC"
|
||||
default:
|
||||
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
|
||||
return
|
||||
}
|
||||
// validate sorting column
|
||||
var n int
|
||||
n, err = strconv.Atoi(sortColumn)
|
||||
if err != nil || n < 0 {
|
||||
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
|
||||
return
|
||||
}
|
||||
// add sorting option to query
|
||||
if i > 0 {
|
||||
sql += ","
|
||||
}
|
||||
sql += fmt.Sprintf("%s %s NULLS LAST", sortColumn, sortOrder)
|
||||
}
|
||||
return
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"tanabata/internal/domain"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
type ErrorMapper struct{}
|
||||
|
||||
func (m *ErrorMapper) MapError(err domain.DomainError) (int, ErrorResponse) {
|
||||
switch err.Code {
|
||||
case domain.ErrCodeFileNotFound:
|
||||
return http.StatusNotFound, ErrorResponse{
|
||||
Error: "Not Found",
|
||||
Code: string(err.Code),
|
||||
Message: err.Message,
|
||||
}
|
||||
case domain.ErrCodeMIMENotSupported:
|
||||
return http.StatusNotFound, ErrorResponse{
|
||||
Error: "MIME not supported",
|
||||
Code: string(err.Code),
|
||||
Message: err.Message,
|
||||
}
|
||||
case domain.ErrCodeBadRequest:
|
||||
return http.StatusNotFound, ErrorResponse{
|
||||
Error: "Bad Request",
|
||||
Code: string(err.Code),
|
||||
Message: err.Message,
|
||||
}
|
||||
}
|
||||
return http.StatusInternalServerError, ErrorResponse{
|
||||
Error: "Internal Server Error",
|
||||
Code: string(err.Code),
|
||||
Message: err.Message,
|
||||
}
|
||||
}
|
||||
178
bot/tfm-tb.py
Normal file
@ -0,0 +1,178 @@
|
||||
#!../venv/bin/python3
|
||||
|
||||
import telebot
|
||||
import sys
|
||||
import os
|
||||
import atexit
|
||||
import signal
|
||||
from time import sleep
|
||||
from socket import getaddrinfo
|
||||
from requests import get
|
||||
from subprocess import check_output
|
||||
import logging as log
|
||||
from json import loads as ljson
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
import api.tfm_api as tfm_api
|
||||
|
||||
# set logger
|
||||
log.basicConfig(
|
||||
level=log.INFO,
|
||||
filename="/var/log/tfm/tfm-tb.log",
|
||||
filemode="a",
|
||||
format="%(asctime)s | %(threadName)s | %(levelname)s | %(message)s"
|
||||
)
|
||||
|
||||
# actions to do on exit
|
||||
exit_actions = []
|
||||
defer = exit_actions.append
|
||||
def finalize(*args):
|
||||
exec('\n'.join(exit_actions))
|
||||
os._exit(0)
|
||||
atexit.register(finalize)
|
||||
signal.signal(signal.SIGTERM, finalize)
|
||||
signal.signal(signal.SIGINT, finalize)
|
||||
|
||||
# initialize TFM API
|
||||
try:
|
||||
tfm_api.Initialize()
|
||||
tfm = tfm_api.TSession("af6dde9b-7be1-46f2-872e-9a06ce3d3358")
|
||||
except Exception as e:
|
||||
log.critical(f"failed to initialize TFM API: {str(e)}")
|
||||
exit(1)
|
||||
|
||||
# initialize bot
|
||||
tfm_tb = telebot.TeleBot(tfm_api.conf["Telebot"]["Token"])
|
||||
|
||||
TZ = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
# check if user is authorized and if chat exists in db
|
||||
def check_chat(message):
|
||||
return message.from_user.id == 924090228
|
||||
|
||||
|
||||
# file handler
|
||||
@tfm_tb.message_handler(content_types=['document', 'photo', 'audio', 'video', 'voice', 'animation'])
|
||||
def file_handler(message):
|
||||
if not check_chat(message):
|
||||
return
|
||||
notes = None
|
||||
orig_name = None
|
||||
if message.forward_from_chat:
|
||||
notes = f"Telegram origin: \"{message.forward_from_chat.title}\" ({message.forward_from_chat.username})"
|
||||
if message.photo:
|
||||
fname = f"{message.photo[-1].file_unique_id}"
|
||||
log.info(f"got photo '{fname}'")
|
||||
file_info = tfm_tb.get_file(message.photo[-1].file_id)
|
||||
file_path = os.path.join(tfm_api.conf["Telebot"]["SaveFolder"], fname)
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(tfm_tb.download_file(file_info.file_path))
|
||||
elif message.video:
|
||||
fname = f"{message.video.file_unique_id}"
|
||||
log.info(f"got video '{fname}'")
|
||||
file_info = tfm_tb.get_file(message.video.file_id)
|
||||
file_path = os.path.join(tfm_api.conf["Telebot"]["SaveFolder"], fname)
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(tfm_tb.download_file(file_info.file_path))
|
||||
else:
|
||||
file = None
|
||||
if message.document:
|
||||
file = message.document
|
||||
elif message.animation:
|
||||
file = message.animation
|
||||
else:
|
||||
tfm_tb.reply_to(message, "Unsupported file type.")
|
||||
return
|
||||
log.info(f"got file '{file.file_name}'")
|
||||
orig_name = file.file_name
|
||||
file_info = tfm_tb.get_file(file.file_id)
|
||||
file_path = os.path.join(tfm_api.conf["Telebot"]["SaveFolder"], f"{file.file_unique_id}")
|
||||
try:
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(tfm_tb.download_file(file_info.file_path))
|
||||
log.info(f"downloaded file '{file_path}'")
|
||||
exif = ljson(os.popen(f"exiftool -json \"{file_path}\"").read())[0]
|
||||
dt = exif["FileModifyDate"]
|
||||
if "SubSecCreateDate" in exif.keys():
|
||||
dt = exif["SubSecCreateDate"]
|
||||
if '.' in dt:
|
||||
dt = datetime.strptime(dt, "%Y:%m:%d %H:%M:%S.%f%z")
|
||||
else:
|
||||
dt = datetime.strptime(dt, "%Y:%m:%d %H:%M:%S%z")
|
||||
file_id, ext = tfm.add_file(file_path, dt, notes, orig_name=orig_name)
|
||||
tfm.add_file_to_tag(file_id, "d6d8129a-984d-4451-8c83-d04523ced8a8")
|
||||
except Exception as e:
|
||||
tfm_tb.reply_to(message, "Error: %s" % str(e).split('\n')[0])
|
||||
log.info(f"Error: %s" % str(e).split('\n')[0])
|
||||
os.remove(file_path)
|
||||
log.info(f"removed file '{file_path}'")
|
||||
else:
|
||||
tfm_tb.reply_to(message, "File saved.")
|
||||
log.info(f"imported file '{file_path}'")
|
||||
|
||||
|
||||
# folder scanner
|
||||
@tfm_tb.message_handler(commands=['scan'])
|
||||
def scan(message):
|
||||
tfm_tb.reply_to(message, "Scanning...")
|
||||
log.info("Scanning...")
|
||||
scan_dir = "/srv/share/hfs/misc/tfm_temp/scan"
|
||||
files = []
|
||||
for file in os.listdir(scan_dir):
|
||||
new_file = {"name": file}
|
||||
file = os.path.join(scan_dir, file)
|
||||
if not os.path.isfile(file):
|
||||
continue
|
||||
new_file["path"] = file
|
||||
try:
|
||||
exif = ljson(os.popen(f"exiftool -json \"{file}\"").read())[0]
|
||||
except Exception as e:
|
||||
log.error("Error while parsing EXIF for file '%s': %s" % (file, e))
|
||||
continue
|
||||
dt = exif["FileModifyDate"]
|
||||
if "SubSecDateTimeOriginal" in exif.keys():
|
||||
dt = exif["SubSecDateTimeOriginal"]
|
||||
elif "DateTimeOriginal" in exif.keys():
|
||||
dt = exif["DateTimeOriginal"]
|
||||
if '.' in dt:
|
||||
try:
|
||||
dt = datetime.strptime(dt, "%Y:%m:%d %H:%M:%S.%f%z")
|
||||
except:
|
||||
dt = TZ.localize(datetime.strptime(dt, "%Y:%m:%d %H:%M:%S.%f"))
|
||||
else:
|
||||
try:
|
||||
try:
|
||||
dt = datetime.strptime(dt, "%Y:%m:%d %H:%M:%S%z")
|
||||
except:
|
||||
dt = TZ.localize(datetime.strptime(dt[:19], "%Y:%m:%d %H:%M:%S"))
|
||||
except:
|
||||
log.error("Broken date: %s\t%s" % (new_file, dt))
|
||||
continue
|
||||
new_file["datetime"] = dt
|
||||
files.append(new_file)
|
||||
tfm_tb.reply_to(message, f"{len(files)} files found.")
|
||||
log.info(f"{len(files)} files found.")
|
||||
files.sort(key=lambda f: f["datetime"])
|
||||
for file in files:
|
||||
try:
|
||||
file_id, ext = tfm.add_file(file["path"], file["datetime"])
|
||||
tfm.add_file_to_tag(file_id, "d6d8129a-984d-4451-8c83-d04523ced8a8")
|
||||
except Exception as e:
|
||||
tfm_tb.reply_to(message, f"Error adding file '{file['name']}': {str(e)}")
|
||||
log.error(f"Error adding file '{file['name']}': {str(e)}")
|
||||
tfm_tb.reply_to(message, "Files added.")
|
||||
log.info("Files added.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
log.info("tfm-tb started")
|
||||
defer("log.info(\"tfm-tb stopped\")")
|
||||
while True:
|
||||
try:
|
||||
tfm_tb.polling(none_stop=True)
|
||||
except Exception as e:
|
||||
log.exception("exception on polling")
|
||||
sleep(1)
|
||||
@ -1,592 +0,0 @@
|
||||
--
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
-- Dumped from database version 14.18 (Ubuntu 14.18-0ubuntu0.22.04.1)
|
||||
-- Dumped by pg_dump version 17.4
|
||||
|
||||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET transaction_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
--
|
||||
-- Name: acl; Type: SCHEMA; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE SCHEMA acl;
|
||||
|
||||
|
||||
--
|
||||
-- Name: activity; Type: SCHEMA; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE SCHEMA activity;
|
||||
|
||||
|
||||
--
|
||||
-- Name: data; Type: SCHEMA; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE SCHEMA data;
|
||||
|
||||
|
||||
--
|
||||
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
-- *not* creating schema, since initdb creates it
|
||||
|
||||
|
||||
--
|
||||
-- Name: system; Type: SCHEMA; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE SCHEMA system;
|
||||
|
||||
|
||||
--
|
||||
-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION pgcrypto; Type: COMMENT; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION pgcrypto IS 'cryptographic functions';
|
||||
|
||||
|
||||
--
|
||||
-- Name: uuid-ossp; Type: EXTENSION; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
|
||||
|
||||
|
||||
--
|
||||
-- Name: EXTENSION "uuid-ossp"; Type: COMMENT; Schema: -; Owner: -
|
||||
--
|
||||
|
||||
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
|
||||
|
||||
|
||||
--
|
||||
-- Name: add_file_to_tag_recursive(uuid, uuid); Type: FUNCTION; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
CREATE FUNCTION data.add_file_to_tag_recursive(f_id uuid, t_id uuid) RETURNS SETOF uuid
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
tmp uuid;
|
||||
tt_id uuid;
|
||||
ttt_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO data.file_tag VALUES (f_id, t_id) ON CONFLICT DO NOTHING RETURNING tag_id INTO tmp;
|
||||
IF tmp IS NULL THEN
|
||||
RETURN;
|
||||
END IF;
|
||||
RETURN NEXT t_id;
|
||||
FOR tt_id IN
|
||||
SELECT a.add_tag_id FROM data.autotags a WHERE a.trigger_tag_id=t_id AND a.is_active
|
||||
LOOP
|
||||
FOR ttt_id IN SELECT data.add_file_to_tag_recursive(f_id, tt_id)
|
||||
LOOP
|
||||
RETURN NEXT ttt_id;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
--
|
||||
-- Name: uuid_extract_timestamp(uuid); Type: FUNCTION; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.uuid_extract_timestamp(uuid_val uuid) RETURNS timestamp with time zone
|
||||
LANGUAGE sql IMMUTABLE
|
||||
AS $$
|
||||
SELECT to_timestamp(
|
||||
('x' || LEFT(REPLACE(uuid_val::TEXT, '-', ''), 12))::BIT(48)::BIGINT
|
||||
/ 1000.0
|
||||
);
|
||||
$$;
|
||||
|
||||
|
||||
--
|
||||
-- Name: uuid_v7(timestamp with time zone); Type: FUNCTION; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.uuid_v7(cts timestamp with time zone DEFAULT clock_timestamp()) RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
state text = current_setting('uuidv7.old_tp',true);
|
||||
old_tp text = split_part(state, ':',1);
|
||||
base int = coalesce(nullif(split_part(state,':',4),'')::int,(random()*16777215/2-1)::int);
|
||||
tp text;
|
||||
entropy text;
|
||||
seq text=base;
|
||||
seqn int=split_part(state,':',2);
|
||||
ver text = coalesce(split_part(state,':',3),to_hex(8+(random()*3)::int));
|
||||
BEGIN
|
||||
base = (random()*16777215/2-1)::int;
|
||||
tp = lpad(to_hex(floor(extract(epoch from cts)*1000)::int8),12,'0')||'7';
|
||||
if tp is distinct from old_tp then
|
||||
old_tp = tp;
|
||||
ver = to_hex(8+(random()*3)::int);
|
||||
base = (random()*16777215/2-1)::int;
|
||||
seqn = base;
|
||||
else
|
||||
seqn = seqn+(random()*1000)::int;
|
||||
end if;
|
||||
perform set_config('uuidv7.old_tp',old_tp||':'||seqn||':'||ver||':'||base, false);
|
||||
entropy = md5(gen_random_uuid()::text);
|
||||
seq = lpad(to_hex(seqn),6,'0');
|
||||
return (tp || substring(seq from 1 for 3) || ver || substring(seq from 4 for 3) ||
|
||||
substring(entropy from 1 for 12))::uuid;
|
||||
END
|
||||
$$;
|
||||
|
||||
|
||||
SET default_table_access_method = heap;
|
||||
|
||||
--
|
||||
-- Name: categories; Type: TABLE; Schema: acl; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE acl.categories (
|
||||
user_id smallint NOT NULL,
|
||||
category_id uuid NOT NULL,
|
||||
view boolean NOT NULL,
|
||||
edit boolean NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: files; Type: TABLE; Schema: acl; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE acl.files (
|
||||
user_id smallint NOT NULL,
|
||||
file_id uuid NOT NULL,
|
||||
view boolean NOT NULL,
|
||||
edit boolean NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: pools; Type: TABLE; Schema: acl; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE acl.pools (
|
||||
user_id smallint NOT NULL,
|
||||
pool_id uuid NOT NULL,
|
||||
view boolean NOT NULL,
|
||||
edit boolean NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags; Type: TABLE; Schema: acl; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE acl.tags (
|
||||
user_id smallint NOT NULL,
|
||||
tag_id uuid NOT NULL,
|
||||
view boolean NOT NULL,
|
||||
edit boolean NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_views; Type: TABLE; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE activity.file_views (
|
||||
file_id uuid NOT NULL,
|
||||
"timestamp" timestamp with time zone NOT NULL,
|
||||
user_id smallint NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: pool_views; Type: TABLE; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE activity.pool_views (
|
||||
pool_id uuid NOT NULL,
|
||||
"timestamp" timestamp with time zone NOT NULL,
|
||||
user_id smallint NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: sessions; Type: TABLE; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE activity.sessions (
|
||||
id integer NOT NULL,
|
||||
token text NOT NULL,
|
||||
user_id smallint NOT NULL,
|
||||
user_agent character varying(256) NOT NULL,
|
||||
started_at timestamp with time zone DEFAULT statement_timestamp() NOT NULL,
|
||||
expires_at timestamp with time zone,
|
||||
last_activity timestamp with time zone DEFAULT statement_timestamp() NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: sessions_id_seq; Type: SEQUENCE; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE activity.sessions_id_seq
|
||||
AS integer
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: sessions_id_seq; Type: SEQUENCE OWNED BY; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE activity.sessions_id_seq OWNED BY activity.sessions.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: tag_uses; Type: TABLE; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE activity.tag_uses (
|
||||
tag_id uuid NOT NULL,
|
||||
"timestamp" timestamp with time zone NOT NULL,
|
||||
user_id smallint NOT NULL,
|
||||
included boolean NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: autotags; Type: TABLE; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE data.autotags (
|
||||
trigger_tag_id uuid NOT NULL,
|
||||
add_tag_id uuid NOT NULL,
|
||||
is_active boolean DEFAULT true NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: categories; Type: TABLE; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE data.categories (
|
||||
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
||||
name character varying(256) NOT NULL,
|
||||
notes text DEFAULT ''::text NOT NULL,
|
||||
color character(6),
|
||||
creator_id smallint NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_pool; Type: TABLE; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE data.file_pool (
|
||||
file_id uuid NOT NULL,
|
||||
pool_id uuid NOT NULL,
|
||||
number smallint NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_tag; Type: TABLE; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE data.file_tag (
|
||||
file_id uuid NOT NULL,
|
||||
tag_id uuid NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: files; Type: TABLE; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE data.files (
|
||||
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
||||
name character varying(256),
|
||||
mime_id smallint NOT NULL,
|
||||
datetime timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
|
||||
notes text,
|
||||
metadata jsonb NOT NULL,
|
||||
creator_id smallint NOT NULL,
|
||||
is_deleted boolean DEFAULT false NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: pools; Type: TABLE; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE data.pools (
|
||||
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
||||
name character varying(256) NOT NULL,
|
||||
notes text,
|
||||
creator_id smallint NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags; Type: TABLE; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE data.tags (
|
||||
id uuid DEFAULT public.uuid_v7() NOT NULL,
|
||||
name character varying(256) NOT NULL,
|
||||
notes text,
|
||||
color character(6),
|
||||
category_id uuid,
|
||||
creator_id smallint NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: mime; Type: TABLE; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE system.mime (
|
||||
id smallint NOT NULL,
|
||||
name character varying(127) NOT NULL,
|
||||
extension character varying(16) NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: mime_id_seq; Type: SEQUENCE; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE system.mime_id_seq
|
||||
AS smallint
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: mime_id_seq; Type: SEQUENCE OWNED BY; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE system.mime_id_seq OWNED BY system.mime.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: users; Type: TABLE; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
CREATE TABLE system.users (
|
||||
id smallint NOT NULL,
|
||||
name character varying(32) NOT NULL,
|
||||
password text NOT NULL,
|
||||
is_admin boolean DEFAULT false NOT NULL,
|
||||
can_create boolean DEFAULT false NOT NULL
|
||||
);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_id_seq; Type: SEQUENCE; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
CREATE SEQUENCE system.users_id_seq
|
||||
AS smallint
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
|
||||
--
|
||||
-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
ALTER SEQUENCE system.users_id_seq OWNED BY system.users.id;
|
||||
|
||||
|
||||
--
|
||||
-- Name: sessions id; Type: DEFAULT; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY activity.sessions ALTER COLUMN id SET DEFAULT nextval('activity.sessions_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: mime id; Type: DEFAULT; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY system.mime ALTER COLUMN id SET DEFAULT nextval('system.mime_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users id; Type: DEFAULT; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY system.users ALTER COLUMN id SET DEFAULT nextval('system.users_id_seq'::regclass);
|
||||
|
||||
|
||||
--
|
||||
-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY acl.categories
|
||||
ADD CONSTRAINT categories_pkey PRIMARY KEY (user_id, category_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: files files_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY acl.files
|
||||
ADD CONSTRAINT files_pkey PRIMARY KEY (user_id, file_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: pools pools_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY acl.pools
|
||||
ADD CONSTRAINT pools_pkey PRIMARY KEY (user_id, pool_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: acl; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY acl.tags
|
||||
ADD CONSTRAINT tags_pkey PRIMARY KEY (user_id, tag_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_views file_views_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY activity.file_views
|
||||
ADD CONSTRAINT file_views_pkey PRIMARY KEY (file_id, "timestamp", user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: pool_views pool_views_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY activity.pool_views
|
||||
ADD CONSTRAINT pool_views_pkey PRIMARY KEY (pool_id, "timestamp", user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: sessions sessions_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY activity.sessions
|
||||
ADD CONSTRAINT sessions_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tag_uses tag_uses_pkey; Type: CONSTRAINT; Schema: activity; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY activity.tag_uses
|
||||
ADD CONSTRAINT tag_uses_pkey PRIMARY KEY (tag_id, "timestamp", user_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: autotags autotags_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY data.autotags
|
||||
ADD CONSTRAINT autotags_pkey PRIMARY KEY (trigger_tag_id, add_tag_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: categories categories_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY data.categories
|
||||
ADD CONSTRAINT categories_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_pool file_pool_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY data.file_pool
|
||||
ADD CONSTRAINT file_pool_pkey PRIMARY KEY (file_id, pool_id, number);
|
||||
|
||||
|
||||
--
|
||||
-- Name: file_tag file_tag_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY data.file_tag
|
||||
ADD CONSTRAINT file_tag_pkey PRIMARY KEY (file_id, tag_id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: files files_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY data.files
|
||||
ADD CONSTRAINT files_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: pools pools_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY data.pools
|
||||
ADD CONSTRAINT pools_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: tags tags_pkey; Type: CONSTRAINT; Schema: data; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY data.tags
|
||||
ADD CONSTRAINT tags_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: mime mime_pkey; Type: CONSTRAINT; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY system.mime
|
||||
ADD CONSTRAINT mime_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: users users_pkey; Type: CONSTRAINT; Schema: system; Owner: -
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY system.users
|
||||
ADD CONSTRAINT users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
212
docs/erd.puml
@ -1,212 +0,0 @@
|
||||
@startuml Tanabata File Manager entity relationship diagram
|
||||
|
||||
' skinparam linetype ortho
|
||||
|
||||
|
||||
' ========== SYSTEM ==========
|
||||
|
||||
entity "system.users" as usr {
|
||||
* id : smallserial <<generated>>
|
||||
--
|
||||
* name : varchar(32)
|
||||
* password : text
|
||||
* is_admin : boolean
|
||||
* can_create : boolean
|
||||
}
|
||||
|
||||
entity "system.mime" as mime {
|
||||
* id : smallserial <<generated>>
|
||||
--
|
||||
* name : varchar(127)
|
||||
* extension : varchar(16)
|
||||
}
|
||||
|
||||
|
||||
' ========== DATA ==========
|
||||
|
||||
entity "data.categories" as cty {
|
||||
* id : uuid <<generated>>
|
||||
--
|
||||
* name : varchar(256)
|
||||
notes : text
|
||||
color : char(6)
|
||||
' * created_at : timestamptz <<generated>>
|
||||
* creator_id : smallint
|
||||
' * is_private : boolean
|
||||
}
|
||||
|
||||
cty::creator_id }o--|| usr::id
|
||||
|
||||
entity "data.files" as fle {
|
||||
* id : uuid <<generated>>
|
||||
--
|
||||
name : varchar(256)
|
||||
* mime_id : smallint
|
||||
* datetime : timestamptz
|
||||
notes : text
|
||||
* metadata : jsonb
|
||||
' * created_at : timestamptz <<generated>>
|
||||
* creator_id : smallint
|
||||
' * is_private : boolean
|
||||
* is_deleted : boolean
|
||||
}
|
||||
|
||||
fle::mime_id }o--|| mime::id
|
||||
fle::creator_id }o--|| usr::id
|
||||
|
||||
entity "data.tags" as tag {
|
||||
* id : uuid <<generated>>
|
||||
--
|
||||
* name : varchar(256)
|
||||
notes : text
|
||||
color : char(6)
|
||||
category_id : uuid
|
||||
' * created_at : timestamptz <<generated>>
|
||||
* creator_id : smallint
|
||||
' * is_private : boolean
|
||||
}
|
||||
|
||||
tag::category_id }o--o| cty::id
|
||||
tag::creator_id }o--|| usr::id
|
||||
|
||||
entity "data.file_tag" as ft {
|
||||
* file_id : uuid
|
||||
* tag_id : uuid
|
||||
}
|
||||
|
||||
ft::file_id }o--|| fle::id
|
||||
ft::tag_id }o--|| tag::id
|
||||
|
||||
entity "data.autotags" as atg {
|
||||
* trigger_tag_id : uuid
|
||||
* add_tag_id : uuid
|
||||
--
|
||||
* is_active : boolean
|
||||
}
|
||||
|
||||
atg::trigger_tag_id }o--|| tag::id
|
||||
atg::add_tag_id }o--|| tag::id
|
||||
|
||||
entity "data.pools" as pool {
|
||||
* id : uuid <<generated>>
|
||||
--
|
||||
* name : varchar(256)
|
||||
notes : text
|
||||
' parent_id : uuid
|
||||
' * created_at : timestamptz
|
||||
* creator_id : smallint
|
||||
' * is_private : boolean
|
||||
}
|
||||
|
||||
pool::creator_id }o--|| usr::id
|
||||
' pool::parent_id }o--o| pool::id
|
||||
|
||||
entity "data.file_pool" as fp {
|
||||
* file_id : uuid
|
||||
* pool_id : uuid
|
||||
* number : smallint
|
||||
}
|
||||
|
||||
fp::file_id }o--|| fle::id
|
||||
fp::pool_id }o--|| pool::id
|
||||
|
||||
|
||||
' ========== ACL ==========
|
||||
|
||||
entity "acl.files" as acl_f {
|
||||
* user_id : smallint
|
||||
* file_id : uuid
|
||||
--
|
||||
* view : boolean
|
||||
* edit : boolean
|
||||
}
|
||||
|
||||
acl_f::user_id }o--|| usr::id
|
||||
acl_f::file_id }o--|| fle::id
|
||||
|
||||
entity "acl.tags" as acl_t {
|
||||
* user_id : smallint
|
||||
* tag_id : uuid
|
||||
--
|
||||
* view : boolean
|
||||
* edit : boolean
|
||||
' * files_view : boolean
|
||||
' * files_edit : boolean
|
||||
}
|
||||
|
||||
acl_t::user_id }o--|| usr::id
|
||||
acl_t::tag_id }o--|| tag::id
|
||||
|
||||
entity "acl.categories" as acl_c {
|
||||
* user_id : smallint
|
||||
* category_id : uuid
|
||||
--
|
||||
* view : boolean
|
||||
* edit : boolean
|
||||
' * tags_view : boolean
|
||||
' * tags_edit : boolean
|
||||
}
|
||||
|
||||
acl_c::user_id }o--|| usr::id
|
||||
acl_c::category_id }o--|| cty::id
|
||||
|
||||
entity "acl.pools" as acl_p {
|
||||
* user_id : smallint
|
||||
* pool_id : uuid
|
||||
--
|
||||
* view : boolean
|
||||
* edit : boolean
|
||||
' * files_view : boolean
|
||||
' * files_edit : boolean
|
||||
}
|
||||
|
||||
acl_p::user_id }o--|| usr::id
|
||||
acl_p::pool_id }o--|| pool::id
|
||||
|
||||
|
||||
' ========== ACTIVITY ==========
|
||||
|
||||
entity "activity.sessions" as ssn {
|
||||
* id : serial <<generated>>
|
||||
--
|
||||
* token : text
|
||||
* user_id : smallint
|
||||
* user_agent : varchar(512)
|
||||
* started_at : timestamptz
|
||||
expires_at : timestamptz
|
||||
* last_activity : timestamptz
|
||||
}
|
||||
|
||||
ssn::user_id }o--|| usr::id
|
||||
|
||||
entity "activity.file_views" as fv {
|
||||
* file_id : uuid
|
||||
* timestamp : timestamptz
|
||||
* user_id : smallint
|
||||
}
|
||||
|
||||
fv::file_id }o--|| fle::id
|
||||
fv::user_id }o--|| usr::id
|
||||
|
||||
entity "activity.tag_uses" as tu {
|
||||
* tag_id : uuid
|
||||
* timestamp : timestamptz
|
||||
* user_id : smallint
|
||||
--
|
||||
* included : boolean
|
||||
}
|
||||
|
||||
tu::tag_id }o--|| tag::id
|
||||
tu::user_id }o--|| usr::id
|
||||
|
||||
entity "activity.pool_views" as pv {
|
||||
* pool_id : uuid
|
||||
* timestamp : timestamptz
|
||||
* user_id : smallint
|
||||
}
|
||||
|
||||
pv::pool_id }o--|| pool::id
|
||||
pv::user_id }o--|| usr::id
|
||||
|
||||
|
||||
@enduml
|
||||
25
requirements.txt
Normal file
@ -0,0 +1,25 @@
|
||||
blinker==1.9.0
|
||||
certifi==2024.12.14
|
||||
charset-normalizer==3.4.1
|
||||
click==8.1.8
|
||||
ffmpeg-python==0.2.0
|
||||
filelock==3.16.1
|
||||
Flask==3.1.0
|
||||
Flask-Cors==5.0.0
|
||||
future==1.0.0
|
||||
idna==3.10
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.5
|
||||
MarkupSafe==3.0.2
|
||||
preview_generator==0.29
|
||||
psycopg2-binary==2.9.10
|
||||
pyexifinfo==0.4.0
|
||||
pyTelegramBotAPI==4.25.0
|
||||
python-magic==0.4.27
|
||||
pytz==2024.2
|
||||
requests==2.32.3
|
||||
ua-parser==1.0.0
|
||||
ua-parser-builtins==0.18.0.post1
|
||||
urllib3==2.3.0
|
||||
Wand==0.6.13
|
||||
Werkzeug==3.1.3
|
||||
36
web/static/css/auth.css
Normal file
@ -0,0 +1,36 @@
|
||||
body {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.decoration {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.decoration.left {
|
||||
left: 0;
|
||||
width: 20vw;
|
||||
}
|
||||
|
||||
.decoration.right {
|
||||
right: 0;
|
||||
width: 20vw;
|
||||
}
|
||||
|
||||
#auth {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#auth h1 {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
#auth .form-control {
|
||||
margin: 14px 0;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
#login {
|
||||
margin-top: 20px;
|
||||
}
|
||||
6
web/static/css/bootstrap.min.css
vendored
Normal file
1
web/static/css/bootstrap.min.css.map
Normal file
54
web/static/css/general.css
Normal file
@ -0,0 +1,54 @@
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #312F45;
|
||||
color: #f0f0f0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
font-family: Epilogue;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 50px;
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #9592B5;
|
||||
border-color: #454261;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #7D7AA4;
|
||||
border-color: #454261;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #DB6060;
|
||||
border-color: #851E1E;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #D64848;
|
||||
border-color: #851E1E;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
388
web/static/css/interface.css
Normal file
@ -0,0 +1,388 @@
|
||||
header, footer {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 20px;
|
||||
box-shadow: 0 5px 5px #0004;
|
||||
}
|
||||
|
||||
.icon-header {
|
||||
height: .8em;
|
||||
}
|
||||
|
||||
#select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sorting {
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#sorting {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
color: #9999AD;
|
||||
}
|
||||
|
||||
#icon-expand {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
#sorting-options {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 114%;
|
||||
padding: 4px 10px;
|
||||
box-sizing: border-box;
|
||||
background-color: #111118;
|
||||
border-radius: 10px;
|
||||
text-align: left;
|
||||
box-shadow: 0 0 10px black;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.sorting-option {
|
||||
padding: 4px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.sorting-option input[type="radio"] {
|
||||
float: unset;
|
||||
margin-left: 1.8em;
|
||||
}
|
||||
|
||||
.filtering-wrapper {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.filtering-block {
|
||||
position: absolute;
|
||||
top: 128px;
|
||||
left: 14px;
|
||||
right: 14px;
|
||||
padding: 14px;
|
||||
background-color: #111118;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px 4px #0004;
|
||||
z-index: 9998;
|
||||
}
|
||||
|
||||
main {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
main:after {
|
||||
content: "";
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.item-preview {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-selected:after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
background-image: url("/static/images/icon-select.svg");
|
||||
background-size: contain;
|
||||
background-position: right;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.file-preview {
|
||||
margin: 1px 0;
|
||||
padding: 0;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
max-width: calc(33vw - 7px);
|
||||
max-height: calc(33vw - 7px);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
}
|
||||
|
||||
.file-preview .overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: #0002;
|
||||
}
|
||||
|
||||
.file-preview:hover .overlay {
|
||||
background-color: #0004;
|
||||
}
|
||||
|
||||
.tag-preview, .filtering-token {
|
||||
margin: 5px 5px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
background-color: #444455;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.category-preview {
|
||||
margin: 5px 5px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
background-color: #444455;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 100vw;
|
||||
min-height: 100vh;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.file .preview-img {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.selection-manager {
|
||||
position: fixed;
|
||||
left: 10px;
|
||||
right: 10px;
|
||||
bottom: 65px;
|
||||
box-sizing: border-box;
|
||||
max-height: 40vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 15px 10px;
|
||||
background-color: #181721;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 5px #0008;
|
||||
}
|
||||
|
||||
.selection-manager hr {
|
||||
margin: 5px 0;
|
||||
}
|
||||
|
||||
.selection-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.selection-header > * {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#selection-edit-tags {
|
||||
color: #4DC7ED;
|
||||
}
|
||||
|
||||
#selection-add-to-pool {
|
||||
color: #F5E872;
|
||||
}
|
||||
|
||||
#selection-delete {
|
||||
color: #DB6060;
|
||||
}
|
||||
|
||||
.selection-tags {
|
||||
max-height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tags-container, .filtering-operators, .filtering-tokens {
|
||||
padding: 5px;
|
||||
background-color: #212529;
|
||||
border: 1px solid #495057;
|
||||
border-radius: .375rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filtering-operators {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tags-container, .filtering-tokens {
|
||||
margin: 15px 0;
|
||||
height: 200px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.tags-container:after, .filtering-tokens:after {
|
||||
content: "";
|
||||
flex: auto;
|
||||
}
|
||||
|
||||
.tags-container-selected {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
#files-filter {
|
||||
margin-bottom: 0;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.viewer-wrapper {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #000a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
/* overflow-y: scroll;*/
|
||||
}
|
||||
|
||||
.viewer-nav {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.viewer-nav:hover {
|
||||
background-color: #b4adff40;
|
||||
}
|
||||
|
||||
.viewer-nav-prev {
|
||||
left: 0;
|
||||
right: 80vw;
|
||||
}
|
||||
|
||||
.viewer-nav-next {
|
||||
left: 80vw;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.viewer-nav-close {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: unset;
|
||||
height: 15vh;
|
||||
}
|
||||
|
||||
.viewer-nav-icon {
|
||||
width: 20px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.viewer-nav-close > .viewer-nav-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
#viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.sessions-wrapper {
|
||||
padding: 14px;
|
||||
background-color: #111118;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.btn-terminate {
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #0007;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 40px;
|
||||
width: 18vw;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.nav.curr, .nav:hover {
|
||||
background-color: #343249;
|
||||
}
|
||||
|
||||
.navicon {
|
||||
display: block;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
#loader {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: #000a;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loader-wrapper {
|
||||
padding: 15px;
|
||||
border-radius: 12px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.loader-img {
|
||||
max-width: 20vw;
|
||||
max-height: 20vh;
|
||||
}
|
||||
BIN
web/static/fonts/Epilogue-VariableFont_wght.ttf
Normal file
BIN
web/static/images/android-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
web/static/images/android-icon-192x192.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
web/static/images/android-icon-36x36.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
web/static/images/android-icon-48x48.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
web/static/images/android-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web/static/images/android-icon-96x96.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
web/static/images/apple-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
BIN
web/static/images/apple-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
web/static/images/apple-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
web/static/images/apple-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
web/static/images/apple-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
web/static/images/apple-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
web/static/images/apple-icon-60x60.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
web/static/images/apple-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web/static/images/apple-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
web/static/images/apple-icon-precomposed.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
web/static/images/apple-icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
web/static/images/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
web/static/images/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
web/static/images/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
web/static/images/favicon-bg.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
web/static/images/favicon.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
4
web/static/images/icon-add.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.51245 10.9993C4.51245 10.7415 4.61483 10.4944 4.79705 10.3122C4.97928 10.1299 5.22644 10.0276 5.48415 10.0276H10.0097V5.50203C10.0097 5.24432 10.112 4.99717 10.2943 4.81494C10.4765 4.63271 10.7237 4.53033 10.9814 4.53033C11.2391 4.53033 11.4862 4.63271 11.6685 4.81494C11.8507 4.99717 11.9531 5.24432 11.9531 5.50203V10.0276H16.4786C16.7363 10.0276 16.9835 10.1299 17.1657 10.3122C17.3479 10.4944 17.4503 10.7415 17.4503 10.9993C17.4503 11.257 17.3479 11.5041 17.1657 11.6863C16.9835 11.8686 16.7363 11.971 16.4786 11.971H11.9531V16.4965C11.9531 16.7542 11.8507 17.0013 11.6685 17.1836C11.4862 17.3658 11.2391 17.4682 10.9814 17.4682C10.7237 17.4682 10.4765 17.3658 10.2943 17.1836C10.112 17.0013 10.0097 16.7542 10.0097 16.4965V11.971H5.48415C5.22644 11.971 4.97928 11.8686 4.79705 11.6863C4.61483 11.5041 4.51245 11.257 4.51245 10.9993Z" fill="#9999AD"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.91409 0.335277C8.9466 -0.111759 13.0162 -0.111759 17.0487 0.335277C19.4157 0.599579 21.3267 2.46394 21.604 4.84396C22.0834 8.93416 22.0834 13.0658 21.604 17.156C21.3254 19.536 19.4144 21.3991 17.0487 21.6647C13.0162 22.1118 8.9466 22.1118 4.91409 21.6647C2.54704 21.3991 0.636031 19.536 0.358773 17.156C-0.119591 13.0659 -0.119591 8.93404 0.358773 4.84396C0.636031 2.46394 2.54833 0.599579 4.91409 0.335277ZM16.8336 2.26572C12.944 1.83459 9.01873 1.83459 5.12916 2.26572C4.40913 2.3456 3.73705 2.66589 3.2215 3.17486C2.70595 3.68383 2.37704 4.35174 2.28792 5.07069C1.82714 9.01056 1.82714 12.9907 2.28792 16.9306C2.37732 17.6493 2.70634 18.3169 3.22186 18.8256C3.73739 19.3343 4.40932 19.6544 5.12916 19.7343C8.98616 20.1644 12.9766 20.1644 16.8336 19.7343C17.5532 19.6542 18.2248 19.3339 18.7401 18.8253C19.2554 18.3166 19.5842 17.6491 19.6735 16.9306C20.1343 12.9907 20.1343 9.01056 19.6735 5.07069C19.5839 4.3524 19.255 3.68524 18.7397 3.17681C18.2245 2.66839 17.553 2.34835 16.8336 2.26831" fill="#9999AD"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
5
web/static/images/icon-category.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.875 9.25C21.1532 9.25 23 7.40317 23 5.125C23 2.84683 21.1532 1 18.875 1C16.5968 1 14.75 2.84683 14.75 5.125C14.75 7.40317 16.5968 9.25 18.875 9.25Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.125 23C7.40317 23 9.25 21.1532 9.25 18.875C9.25 16.5968 7.40317 14.75 5.125 14.75C2.84683 14.75 1 16.5968 1 18.875C1 21.1532 2.84683 23 5.125 23Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.75 14.75H23V21.625C23 21.9897 22.8551 22.3394 22.5973 22.5973C22.3394 22.8551 21.9897 23 21.625 23H16.125C15.7603 23 15.4106 22.8551 15.1527 22.5973C14.8949 22.3394 14.75 21.9897 14.75 21.625V14.75ZM1 1H9.25V7.875C9.25 8.23967 9.10513 8.58941 8.84727 8.84727C8.58941 9.10513 8.23967 9.25 7.875 9.25H2.375C2.01033 9.25 1.66059 9.10513 1.40273 8.84727C1.14487 8.58941 1 8.23967 1 7.875V1Z" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
3
web/static/images/icon-expand.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="9" viewBox="0 0 16 9" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.10279 7.27726L14.8104 0.579294C14.8755 0.513843 14.9531 0.462123 15.0386 0.427199C15.1241 0.392275 15.2157 0.374857 15.308 0.375976C15.4003 0.377095 15.4915 0.396729 15.5761 0.433714C15.6607 0.470699 15.737 0.524284 15.8005 0.591294C15.9306 0.728358 16.0022 0.910756 15.9999 1.09973C15.9977 1.2887 15.9219 1.46935 15.7885 1.60329L8.58484 8.79625C8.5202 8.86132 8.44324 8.91285 8.35845 8.94783C8.27366 8.98282 8.18275 9.00055 8.09103 8.99999C7.99931 8.99943 7.90862 8.98059 7.82427 8.94458C7.73991 8.90857 7.66359 8.8561 7.59975 8.79025L0.204043 1.21929C0.0731536 1.08376 0 0.902704 0 0.714294C0 0.525883 0.0731536 0.344832 0.204043 0.209296C0.268362 0.143072 0.345316 0.0904251 0.43035 0.0544745C0.515384 0.0185239 0.606768 0 0.69909 0C0.791413 0 0.882797 0.0185239 0.967831 0.0544745C1.05286 0.0904251 1.12982 0.143072 1.19414 0.209296L8.10279 7.27726Z" fill="#9999AD"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 985 B |
4
web/static/images/icon-file.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.0465 20.4651H8.95346V22H13.0465V20.4651ZM1.53488 13.0465V8.95352H1.29521e-06V13.0465H1.53488ZM20.4651 12.5994V13.0465H21.9999V12.5994H20.4651ZM13.9582 3.43921L18.0092 7.08506L19.0356 5.94311L14.9855 2.29726L13.9582 3.43921ZM21.9999 12.5994C21.9999 10.8711 22.0153 9.77621 21.5804 8.79798L20.1775 9.42319C20.4497 10.0351 20.4651 10.736 20.4651 12.5994H21.9999ZM18.0092 7.08506C19.3937 8.33138 19.9053 8.81231 20.1775 9.42319L21.5804 8.79798C21.1445 7.81873 20.3208 7.09938 19.0356 5.94311L18.0092 7.08506ZM8.98416 1.53494C10.6029 1.53494 11.2138 1.54722 11.7572 1.75596L12.3077 0.323405C11.4359 -0.012222 10.4863 5.70826e-05 8.98416 5.70826e-05V1.53494ZM14.9855 2.29828C13.8743 1.29856 13.1795 0.656985 12.3077 0.323405L11.7582 1.75596C12.3026 1.9647 12.761 2.36172 13.9582 3.43921L14.9855 2.29828ZM8.95346 20.4651C7.00212 20.4651 5.61664 20.4631 4.56371 20.3219C3.53534 20.1837 2.94185 19.9238 2.50902 19.491L1.42437 20.5756C2.18976 21.3431 3.16083 21.6818 4.36008 21.8434C5.53682 22.002 7.04612 22 8.95346 22V20.4651ZM1.29521e-06 13.0465C1.29521e-06 14.9539 -0.00204521 16.4621 0.156559 17.6399C0.318233 18.8392 0.657953 19.8102 1.42335 20.5766L2.50799 19.492C2.07618 19.0581 1.81627 18.4646 1.67814 17.4353C1.53693 16.3844 1.53488 14.9979 1.53488 13.0465H1.29521e-06ZM13.0465 22C14.9538 22 16.4621 22.002 17.6399 21.8434C18.8391 21.6818 19.8102 21.342 20.5766 20.5766L19.4919 19.492C19.0581 19.9238 18.4646 20.1837 17.4352 20.3219C16.3843 20.4631 14.9978 20.4651 13.0465 20.4651V22ZM20.4651 13.0465C20.4651 14.9979 20.463 16.3844 20.3218 17.4363C20.1837 18.4647 19.9238 19.0581 19.4909 19.491L20.5756 20.5756C21.343 19.8102 21.6817 18.8392 21.8434 17.6399C22.002 16.4632 21.9999 14.9539 21.9999 13.0465H20.4651ZM1.53488 8.95352C1.53488 7.00217 1.53693 5.61669 1.67814 4.56376C1.81627 3.53539 2.07618 2.94191 2.50902 2.50907L1.42437 1.42442C0.656929 2.18982 0.318233 3.16088 0.156559 4.36014C-0.00204521 5.53688 1.29521e-06 7.04617 1.29521e-06 8.95352H1.53488ZM8.98416 5.70826e-05C7.06556 5.70826e-05 5.55012 -0.00198942 4.36827 0.156615C3.1639 0.318289 2.18976 0.658009 1.42335 1.4234L2.50799 2.50805C2.94185 2.07624 3.53636 1.81633 4.57189 1.67819C5.62891 1.53698 7.02258 1.53494 8.98416 1.53494V5.70826e-05Z" fill="#9999AD"/>
|
||||
<path d="M12.0232 1.27905V3.83718C12.0232 6.24899 12.0232 7.45541 12.7722 8.20443C13.5212 8.95345 14.7276 8.95345 17.1395 8.95345H21.2325" stroke="#9999AD" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
3
web/static/images/icon-pool.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.07102 2.7508H4.64702C4.80588 1.9743 5.22797 1.27646 5.84196 0.775241C6.45595 0.274027 7.22417 0.000183205 8.01676 0H16.7276C18.1259 0 19.467 0.55548 20.4558 1.54424C21.4445 2.533 22 3.87405 22 5.27237V13.9832C22 14.7743 21.7273 15.5411 21.2279 16.1546C20.7285 16.7681 20.033 17.1907 19.2584 17.3511V15.9253C19.6583 15.7816 20.0042 15.518 20.2487 15.1704C20.4932 14.8228 20.6245 14.4082 20.6246 13.9832V5.27237C20.6246 4.23883 20.214 3.24762 19.4832 2.5168C18.7524 1.78597 17.7612 1.3754 16.7276 1.3754H8.01676C7.59001 1.37527 7.17373 1.50748 6.82525 1.75381C6.47678 2.00014 6.21327 2.34846 6.07102 2.7508ZM3.4385 3.66774C2.52656 3.66774 1.65196 4.03001 1.00711 4.67485C0.36227 5.3197 0 6.19429 0 7.10624V18.5679C0 19.4799 0.36227 20.3545 1.00711 20.9993C1.65196 21.6442 2.52656 22.0064 3.4385 22.0064H14.9002C15.8121 22.0064 16.6867 21.6442 17.3316 20.9993C17.9764 20.3545 18.3387 19.4799 18.3387 18.5679V7.10624C18.3387 6.19429 17.9764 5.3197 17.3316 4.67485C16.6867 4.03001 15.8121 3.66774 14.9002 3.66774H3.4385ZM1.3754 7.10624C1.3754 6.55907 1.59276 6.03431 1.97967 5.64741C2.36658 5.2605 2.89133 5.04314 3.4385 5.04314H14.9002C15.4473 5.04314 15.9721 5.2605 16.359 5.64741C16.7459 6.03431 16.9633 6.55907 16.9633 7.10624V18.5679C16.9633 19.1151 16.7459 19.6398 16.359 20.0268C15.9721 20.4137 15.4473 20.631 14.9002 20.631H3.4385C2.89133 20.631 2.36658 20.4137 1.97967 20.0268C1.59276 19.6398 1.3754 19.1151 1.3754 18.5679V7.10624Z" fill="#9999AD"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
3
web/static/images/icon-select.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.414 7.97C1.78906 7.59506 2.29767 7.38443 2.828 7.38443C3.35833 7.38443 3.86695 7.59506 4.242 7.97L11.314 15.042L25.454 0.900004C25.6397 0.714184 25.8602 0.566757 26.1028 0.466141C26.3455 0.365526 26.6056 0.313692 26.8683 0.313599C27.131 0.313506 27.3911 0.365156 27.6339 0.4656C27.8766 0.566044 28.0972 0.713315 28.283 0.899004C28.4688 1.08469 28.6163 1.30516 28.7169 1.54783C28.8175 1.79049 28.8693 2.0506 28.8694 2.3133C28.8695 2.57599 28.8179 2.83614 28.7174 3.07887C28.617 3.32161 28.4697 3.54218 28.284 3.728L11.314 20.698L1.414 10.798C1.03906 10.4229 0.82843 9.91433 0.82843 9.384C0.82843 8.85368 1.03906 8.34506 1.414 7.97Z" fill="white" stroke="black" stroke-width="1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 794 B |
4
web/static/images/icon-settings.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg width="23" height="24" viewBox="0 0 23 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.371 15.3C13.1935 15.3 14.671 13.8226 14.671 12C14.671 10.1775 13.1935 8.70001 11.371 8.70001C9.54844 8.70001 8.07098 10.1775 8.07098 12C8.07098 13.8226 9.54844 15.3 11.371 15.3Z" stroke="#9999AD" stroke-width="1.5"/>
|
||||
<path d="M13.3125 1.1672C12.9088 1 12.3962 1 11.371 1C10.3458 1 9.8332 1 9.4295 1.1672C9.1624 1.27776 8.91971 1.43989 8.7153 1.6443C8.51089 1.84871 8.34877 2.0914 8.2382 2.3585C8.137 2.6038 8.0963 2.8909 8.0809 3.3078C8.07405 3.60919 7.9907 3.90389 7.8387 4.16423C7.68669 4.42456 7.47101 4.642 7.2119 4.7961C6.94889 4.94355 6.65271 5.02171 6.35119 5.02325C6.04967 5.02479 5.75271 4.94965 5.4882 4.8049C5.1186 4.6091 4.8513 4.5013 4.5862 4.4661C4.00795 4.39006 3.42317 4.54674 2.9604 4.9017C2.615 5.169 2.3576 5.6123 1.845 6.5C1.3324 7.3877 1.075 7.831 1.0189 8.2655C0.981102 8.552 1.00012 8.84314 1.07486 9.12228C1.1496 9.40143 1.2786 9.66312 1.4545 9.8924C1.6173 10.1036 1.845 10.2807 2.1981 10.5029C2.7184 10.8296 3.0528 11.3862 3.0528 12C3.0528 12.6138 2.7184 13.1704 2.1981 13.496C1.845 13.7193 1.6162 13.8964 1.4545 14.1076C1.2786 14.3369 1.1496 14.5986 1.07486 14.8777C1.00012 15.1569 0.981102 15.448 1.0189 15.7345C1.0761 16.1679 1.3324 16.6123 1.8439 17.5C2.3576 18.3877 2.6139 18.831 2.9604 19.0983C3.18968 19.2742 3.45137 19.4032 3.73052 19.4779C4.00967 19.5527 4.30081 19.5717 4.5873 19.5339C4.8513 19.4987 5.1186 19.3909 5.4882 19.1951C5.75271 19.0503 6.04967 18.9752 6.35119 18.9767C6.65271 18.9783 6.94889 19.0565 7.2119 19.2039C7.7432 19.5119 8.0589 20.0784 8.0809 20.6922C8.0963 21.1102 8.1359 21.3962 8.2382 21.6415C8.34877 21.9086 8.51089 22.1513 8.7153 22.3557C8.91971 22.5601 9.1624 22.7222 9.4295 22.8328C9.8332 23 10.3458 23 11.371 23C12.3962 23 12.9088 23 13.3125 22.8328C13.5796 22.7222 13.8223 22.5601 14.0267 22.3557C14.2311 22.1513 14.3932 21.9086 14.5038 21.6415C14.605 21.3962 14.6457 21.1102 14.6611 20.6922C14.6831 20.0784 14.9988 19.5108 15.5301 19.2039C15.7931 19.0565 16.0893 18.9783 16.3908 18.9767C16.6923 18.9752 16.9893 19.0503 17.2538 19.1951C17.6234 19.3909 17.8907 19.4987 18.1547 19.5339C18.4412 19.5717 18.7323 19.5527 19.0115 19.4779C19.2906 19.4032 19.5523 19.2742 19.7816 19.0983C20.1281 18.8321 20.3844 18.3877 20.897 17.5C21.4096 16.6123 21.667 16.169 21.7231 15.7345C21.7609 15.448 21.7419 15.1569 21.6672 14.8777C21.5924 14.5986 21.4634 14.3369 21.2875 14.1076C21.1247 13.8964 20.897 13.7193 20.5439 13.4971C20.2862 13.3405 20.0726 13.1209 19.9231 12.859C19.7736 12.5971 19.6931 12.3015 19.6892 12C19.6892 11.3862 20.0236 10.8296 20.5439 10.504C20.897 10.2807 21.1258 10.1036 21.2875 9.8924C21.4634 9.66312 21.5924 9.40143 21.6672 9.12228C21.7419 8.84314 21.7609 8.552 21.7231 8.2655C21.6659 7.8321 21.4096 7.3877 20.8981 6.5C20.3844 5.6123 20.1281 5.169 19.7816 4.9017C19.5523 4.7258 19.2906 4.59679 19.0115 4.52205C18.7323 4.44731 18.4412 4.4283 18.1547 4.4661C17.8907 4.5013 17.6234 4.6091 17.2527 4.8049C16.9883 4.94946 16.6916 5.02449 16.3903 5.02295C16.089 5.02141 15.793 4.94335 15.5301 4.7961C15.271 4.642 15.0553 4.42456 14.9033 4.16423C14.7513 3.90389 14.668 3.60919 14.6611 3.3078C14.6457 2.8898 14.6061 2.6038 14.5038 2.3585C14.3932 2.0914 14.2311 1.84871 14.0267 1.6443C13.8223 1.43989 13.5796 1.27776 13.3125 1.1672Z" stroke="#9999AD" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
5
web/static/images/icon-tag.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.00067 16.5511C2.30116 14.8505 1.45086 14.0013 1.13515 12.8979C0.818352 11.7946 1.08895 10.6231 1.63016 8.28122L1.94146 6.93041C2.39576 4.9592 2.62346 3.97359 3.29777 3.29819C3.97317 2.62388 4.95878 2.39618 6.92999 1.94188L8.2808 1.62947C10.6238 1.08937 11.7942 0.818769 12.8975 1.13447C14.0008 1.45127 14.85 2.30158 16.5496 4.00109L18.5626 6.0141C21.5227 8.97312 23 10.4515 23 12.2885C23 14.1267 21.5216 15.6051 18.5637 18.563C15.6046 21.522 14.1262 23.0004 12.2881 23.0004C10.4511 23.0004 8.97161 21.522 6.01369 18.5641L4.00067 16.5511Z" stroke="#9999AD" stroke-width="1.5"/>
|
||||
<path d="M9.82325 10.1229C10.6824 9.26375 10.6824 7.87077 9.82325 7.01161C8.96409 6.15246 7.57112 6.15246 6.71196 7.01162C5.8528 7.87077 5.8528 9.26375 6.71196 10.1229C7.57112 10.9821 8.96409 10.9821 9.82325 10.1229Z" stroke="#9999AD" stroke-width="1.5"/>
|
||||
<path d="M11.4962 19.1504L19.1731 11.4724" stroke="#9999AD" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
3
web/static/images/icon-terminate.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.5 25C5.59625 25 0 19.4037 0 12.5C0 5.59625 5.59625 0 12.5 0C19.4037 0 25 5.59625 25 12.5C25 19.4037 19.4037 25 12.5 25ZM12.5 22.5C15.1522 22.5 17.6957 21.4464 19.5711 19.5711C21.4464 17.6957 22.5 15.1522 22.5 12.5C22.5 9.84783 21.4464 7.3043 19.5711 5.42893C17.6957 3.55357 15.1522 2.5 12.5 2.5C9.84783 2.5 7.3043 3.55357 5.42893 5.42893C3.55357 7.3043 2.5 9.84783 2.5 12.5C2.5 15.1522 3.55357 17.6957 5.42893 19.5711C7.3043 21.4464 9.84783 22.5 12.5 22.5ZM6.25 11.25H18.75V13.75H6.25V11.25Z" fill="#DB6060"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
BIN
web/static/images/layer-controls.png
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
BIN
web/static/images/loader.gif
Normal file
|
After Width: | Height: | Size: 972 KiB |
BIN
web/static/images/ms-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
web/static/images/ms-icon-150x150.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
web/static/images/ms-icon-310x310.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
web/static/images/ms-icon-70x70.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
web/static/images/tanabata-left.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
web/static/images/tanabata-right.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
22
web/static/js/add-category.js
Normal file
@ -0,0 +1,22 @@
|
||||
$(document).on("submit", "#object-add", function (e) {
|
||||
e.preventDefault();
|
||||
$("#loader").css("display", "");
|
||||
$.ajax({
|
||||
url: location.pathname,
|
||||
type: "POST",
|
||||
data: $(this).serialize(),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
location.href = location.pathname.substring(0, location.pathname.lastIndexOf("/"));
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
22
web/static/js/add-tag.js
Normal file
@ -0,0 +1,22 @@
|
||||
$(document).on("submit", "#object-add", function (e) {
|
||||
e.preventDefault();
|
||||
$("#loader").css("display", "");
|
||||
$.ajax({
|
||||
url: location.pathname,
|
||||
type: "POST",
|
||||
data: $(this).serialize(),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
location.href = location.pathname.substring(0, location.pathname.lastIndexOf("/"));
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
19
web/static/js/auth.js
Normal file
@ -0,0 +1,19 @@
|
||||
$("#auth").on("submit", function submit(e) {
|
||||
e.preventDefault();
|
||||
$.ajax({
|
||||
url: "/auth",
|
||||
type: "POST",
|
||||
data: $("#auth").serialize(),
|
||||
dataType: "json",
|
||||
success: function(resp) {
|
||||
if (resp.status) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function(err) {
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
20
web/static/js/category.js
Normal file
@ -0,0 +1,20 @@
|
||||
$(document).on("submit", "#object-edit", function (e) {
|
||||
e.preventDefault();
|
||||
$("#loader").css("display", "");
|
||||
$.ajax({
|
||||
url: location.pathname + "/edit",
|
||||
type: "POST",
|
||||
data: $(this).serialize(),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (!resp.status) {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
88
web/static/js/file.js
Normal file
@ -0,0 +1,88 @@
|
||||
$(document).on("input", "#file-tags-filter", function (e) {
|
||||
let filter = $(this).val().toLowerCase();
|
||||
let unfiltered = $("#file-tags-other > .tag-preview");
|
||||
if (filter === "") {
|
||||
unfiltered.css("display", "");
|
||||
return;
|
||||
}
|
||||
unfiltered.each((index, element) => {
|
||||
let current = $(element);
|
||||
if (current.text().toLowerCase().includes(filter)) {
|
||||
current.css("display", "");
|
||||
} else {
|
||||
current.css("display", "none");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", "#file-tags-other > .tag-preview", function (e) {
|
||||
$("#loader").css("display", "");
|
||||
$.ajax({
|
||||
url: location.pathname + "/tag",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({add: true, tag_id: $(this).attr("tag_id")}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
resp.tags.forEach((tag_id) => {
|
||||
$(`#file-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
|
||||
$(`#file-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "");
|
||||
});
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", "#file-tags-selected > .tag-preview", function (e) {
|
||||
$("#loader").css("display", "");
|
||||
let tag_id = $(this).attr("tag_id");
|
||||
$.ajax({
|
||||
url: location.pathname + "/tag",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({add: false, tag_id: $(this).attr("tag_id")}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
$(`#file-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
|
||||
$(`#file-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "");
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("submit", "#object-edit", function (e) {
|
||||
e.preventDefault();
|
||||
$("#loader").css("display", "");
|
||||
$.ajax({
|
||||
url: location.pathname + "/edit",
|
||||
type: "POST",
|
||||
data: $(this).serialize(),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (!resp.status) {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
130
web/static/js/files.js
Normal file
@ -0,0 +1,130 @@
|
||||
var curr_page = 0;
|
||||
var load_lock = false;
|
||||
var init_filter = null;
|
||||
|
||||
function files_load() {
|
||||
if (load_lock) {
|
||||
return;
|
||||
}
|
||||
load_lock = true;
|
||||
let container = $("main");
|
||||
$("#loader").css("display", "");
|
||||
$.ajax({
|
||||
url: "/api/files?limit=50&offset=" + curr_page*50 + (init_filter ? "&filter=" + encodeURIComponent("{" + init_filter + "}") : ""),
|
||||
type: "GET",
|
||||
contentType: "application/json",
|
||||
async: false,
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
resp.forEach((file) => {
|
||||
container.append(`<div class="item-preview file-preview" file_id="${file.id}"><img src="/static/thumbs/${file.id}" alt="" class="file-thumb"><div class="overlay"></div></div>`);
|
||||
});
|
||||
if (resp.length == 50) {
|
||||
load_lock = false;
|
||||
}
|
||||
},
|
||||
error: function (xhr, status) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(xhr.responseText);
|
||||
location.href = "/files";
|
||||
}
|
||||
});
|
||||
curr_page++;
|
||||
}
|
||||
|
||||
function tags_load(target) {
|
||||
$("#loader").css("display", "");
|
||||
$.ajax({
|
||||
url: "/api/tags",
|
||||
type: "GET",
|
||||
contentType: "application/json",
|
||||
async: false,
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
resp.forEach((tag) => {
|
||||
target.append(`<div class="filtering-token" val="t=${tag.id}" style="background-color: #${tag.category_color}">${escapeHTML(tag.name)}</div>`);
|
||||
});
|
||||
},
|
||||
error: function (xhr, status) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function filter_load() {
|
||||
if (!init_filter) {
|
||||
return;
|
||||
}
|
||||
$("#files-filter").html("");
|
||||
let filtering_tokens = init_filter.split(',');
|
||||
filtering_tokens.forEach((element) => {
|
||||
$(`.filtering-block .filtering-token[val='${element}']`).clone().appendTo("#files-filter");
|
||||
});
|
||||
}
|
||||
|
||||
$(window).on("load", function (e) {
|
||||
init_filter = /filter=\{([^\}]+)/.exec(decodeURIComponent(location.search));
|
||||
init_filter = init_filter ? init_filter[1] : null;
|
||||
let container = $("main");
|
||||
while (!load_lock && container.scrollTop() + container.innerHeight() >= container[0].scrollHeight) {
|
||||
files_load();
|
||||
}
|
||||
tags_load($("#filtering-tokens-all"));
|
||||
filter_load();
|
||||
});
|
||||
|
||||
$("main").scroll(function (e) {
|
||||
if ($(this).scrollTop() + $(this).innerHeight() >= $(this)[0].scrollHeight - 100) {
|
||||
files_load();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("click", "#files-filter", function (e) {
|
||||
if ($(".filtering-block").is(":hidden")) {
|
||||
$(".filtering-block").slideDown("fast");
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("click", "#filtering-apply", function (e) {
|
||||
let filtering_tokens = [];
|
||||
$("#files-filter > .filtering-token").each((index, element) => {
|
||||
filtering_tokens.push($(element).attr("val"));
|
||||
});
|
||||
location.href = "/files?filter=" + encodeURIComponent("{" + filtering_tokens.join(',') + "}");
|
||||
});
|
||||
|
||||
$(document).on("click", "#filtering-reset", function (e) {
|
||||
$(".filtering-block").slideUp("fast");
|
||||
filter_load();
|
||||
$("#filter-filtering").val("").trigger("input");
|
||||
});
|
||||
|
||||
$(document).on("click", ".filtering-block .filtering-token", function (e) {
|
||||
$(this).clone().appendTo("#files-filter");
|
||||
});
|
||||
|
||||
$(document).on("click", "#files-filter > .filtering-token", function (e) {
|
||||
$(this).remove();
|
||||
});
|
||||
|
||||
$(document).on("input", "#filter-filtering", function (e) {
|
||||
let filter = $(this).val().toLowerCase();
|
||||
let unfiltered = $("#filtering-tokens-all > .filtering-token");
|
||||
if (filter === "") {
|
||||
unfiltered.css("display", "");
|
||||
return;
|
||||
}
|
||||
unfiltered.each((index, element) => {
|
||||
let current = $(element);
|
||||
if (current.text().toLowerCase().includes(filter)) {
|
||||
current.css("display", "");
|
||||
} else {
|
||||
current.css("display", "none");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", "#filter-filtering", function (e) {
|
||||
$(this).val("").trigger("input");
|
||||
});
|
||||
363
web/static/js/interface.js
Normal file
@ -0,0 +1,363 @@
|
||||
var lazy_loader;
|
||||
|
||||
function escapeHTML(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function beautify_date(date_string) {
|
||||
if (date_string == null) {
|
||||
return null;
|
||||
}
|
||||
let ts = new Date(date_string).getTime();
|
||||
let tz = new Date().getTimezoneOffset();
|
||||
return new Date(ts-tz*60000).toISOString().slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function close_select_manager() {
|
||||
$(".item-selected").removeClass("item-selected");
|
||||
$(".selection-manager").css("display", "none");
|
||||
$("#selection-count").text(0);
|
||||
$("main").css("padding-bottom", "");
|
||||
$("#selection-tags-other > .tag-preview").css("display", "");
|
||||
$("#selection-tags-selected > .tag-preview").css("display", "none");
|
||||
$("#selection-tags-filter").val("").trigger("input");
|
||||
$(".selection-tags").css("display", "none");
|
||||
}
|
||||
|
||||
function refresh_selection_tags() {
|
||||
$("#loader").css("display", "");
|
||||
let file_id_list = [];
|
||||
$("main > .file-preview.item-selected").each((index, element) => {
|
||||
file_id_list.push($(element).attr("file_id"));
|
||||
});
|
||||
$("#selection-tags-other > .tag-preview").css("display", "");
|
||||
$("#selection-tags-selected > .tag-preview").css("display", "none");
|
||||
$.ajax({
|
||||
url: location.pathname + "/tags",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({action: "get", file_id_list: file_id_list}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
resp.tag_id_list.forEach((tag_id) => {
|
||||
$(`#selection-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
|
||||
$(`#selection-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "");
|
||||
});
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function select_handler(curr) {
|
||||
let selection_count = +$("#selection-count").text();
|
||||
if (curr.hasClass("item-selected")) {
|
||||
curr.removeClass("item-selected");
|
||||
selection_count--;
|
||||
$("#selection-count").text(selection_count);
|
||||
if (!selection_count) {
|
||||
close_select_manager();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
curr.addClass("item-selected");
|
||||
$(".selection-manager").css("display", "");
|
||||
$("main").css("padding-bottom", "80px");
|
||||
selection_count++;
|
||||
$("#selection-count").text(selection_count);
|
||||
}
|
||||
refresh_selection_tags();
|
||||
}
|
||||
|
||||
// $(window).on("load", function () {
|
||||
// lazy_loader = $(".file-thumb").Lazy({
|
||||
// scrollDirection: "vertical",
|
||||
// effect: "fadeIn",
|
||||
// visibleOnly: true,
|
||||
// appendScroll: $("main")[0],
|
||||
// chainable: false,
|
||||
// });
|
||||
// });
|
||||
|
||||
$(document).keyup(function (e) {
|
||||
switch (e.key) {
|
||||
case "Esc":
|
||||
case "Escape":
|
||||
close_select_manager();
|
||||
break;
|
||||
// case "Left":
|
||||
// case "ArrowLeft":
|
||||
// if (current_sasa_index >= 0) {
|
||||
// file_prev();
|
||||
// }
|
||||
// break;
|
||||
// case "Right":
|
||||
// case "ArrowRight":
|
||||
// if (current_sasa_index >= 0) {
|
||||
// file_next();
|
||||
// }
|
||||
// break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("selectstart", ".item-preview", function (e) {
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
$(document).on("click", "#select", function (e) {
|
||||
if ($(".selection-manager").is(":visible")) {
|
||||
close_select_manager();
|
||||
return;
|
||||
}
|
||||
$(".selection-manager").css("display", "");
|
||||
$("main").css("padding-bottom", "80px");
|
||||
selection_count++;
|
||||
$("#selection-count").text(selection_count);
|
||||
});
|
||||
|
||||
$(document).on("click", "main > .file-preview", function (e) {
|
||||
e.preventDefault();
|
||||
if ($(".selection-manager").is(":visible")) {
|
||||
select_handler($(this));
|
||||
return;
|
||||
}
|
||||
let id = $(this).attr("file_id");
|
||||
$("#viewer").attr("src", "/files/" + id);
|
||||
$("#view-prev").attr("file_id", $(this).prev(":visible").attr("file_id"));
|
||||
$("#view-next").attr("file_id", $(this).next(":visible").attr("file_id"));
|
||||
$(".viewer-wrapper").css("display", "");
|
||||
});
|
||||
|
||||
$(document).on("click", "main > .tag-preview", function (e) {
|
||||
e.preventDefault();
|
||||
if ($(".selection-manager").is(":visible")) {
|
||||
select_handler($(this));
|
||||
return;
|
||||
}
|
||||
let id = $(this).attr("tag_id");
|
||||
location.href = "/tags/" + id;
|
||||
});
|
||||
|
||||
$(document).on("click", "main > .category-preview", function (e) {
|
||||
e.preventDefault();
|
||||
if ($(".selection-manager").is(":visible")) {
|
||||
select_handler($(this));
|
||||
return;
|
||||
}
|
||||
let id = $(this).attr("category_id");
|
||||
location.href = "/categories/" + id;
|
||||
});
|
||||
|
||||
$(document).on("click", "#sorting", function (e) {
|
||||
$("#sorting-options").slideToggle("fast");
|
||||
if ($("#sorting-options").is(":visible")) {
|
||||
key_prev = $("input[name='sorting'][prev-checked]").val();
|
||||
key_curr = $("input[name='sorting']:checked").val();
|
||||
asc_prev = $("input[name='order'][prev-checked]").val();
|
||||
asc_curr = $("input[name='order']:checked").val();
|
||||
if (key_curr != key_prev || asc_curr != asc_prev) {
|
||||
$.ajax({
|
||||
url: "/settings/sorting",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({[location.pathname.split('/')[1]]: {key: key_curr, asc: (asc_curr == "asc")}}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
if (resp.status) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("input", "#filter", function (e) {
|
||||
let filter = $(this).val().toLowerCase();
|
||||
let unfiltered = $("main > .item-preview");
|
||||
if (filter === "") {
|
||||
unfiltered.css("display", "");
|
||||
return;
|
||||
}
|
||||
unfiltered.each((index, element) => {
|
||||
let current = $(element);
|
||||
if (current.text().toLowerCase().includes(filter)) {
|
||||
current.css("display", "");
|
||||
} else {
|
||||
current.css("display", "none");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", ".filtering", function (e) {
|
||||
$(this).val("").trigger("input");
|
||||
})
|
||||
|
||||
$(document).on("click", "#selection-info", function (e) {
|
||||
close_select_manager();
|
||||
});
|
||||
|
||||
$(document).on("click", "#selection-edit-tags", function (e) {
|
||||
$(".selection-tags").slideToggle("fast");
|
||||
});
|
||||
|
||||
$(document).on("click", "#selection-delete", function (e) {
|
||||
if (!confirm("Delete selected?")) {
|
||||
return;
|
||||
}
|
||||
let file_id_list = [];
|
||||
$("main > .file-preview.item-selected").each((index, element) => {
|
||||
file_id_list.push($(element).attr("file_id"));
|
||||
});
|
||||
$.ajax({
|
||||
url: location.pathname + "/delete",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({file_id_list: file_id_list}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", "#selection-tags-other > .tag-preview", function (e) {
|
||||
$("#loader").css("display", "");
|
||||
let tag_id = $(this).attr("tag_id");
|
||||
let file_id_list = [];
|
||||
$("main > .file-preview.item-selected").each((index, element) => {
|
||||
file_id_list.push($(element).attr("file_id"));
|
||||
});
|
||||
$.ajax({
|
||||
url: location.pathname + "/tags",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({action: "add", file_id_list: file_id_list, tag_id: tag_id}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
resp.tag_id_list.forEach((tag_id) => {
|
||||
$(`#selection-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
|
||||
$(`#selection-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "");
|
||||
});
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", "#selection-tags-selected > .tag-preview", function (e) {
|
||||
$("#loader").css("display", "");
|
||||
let tag_id = $(this).attr("tag_id");
|
||||
let file_id_list = [];
|
||||
$("main > .file-preview.item-selected").each((index, element) => {
|
||||
file_id_list.push($(element).attr("file_id"));
|
||||
});
|
||||
$.ajax({
|
||||
url: location.pathname + "/tags",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({action: "remove", file_id_list: file_id_list, tag_id: tag_id}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
$(`#selection-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
|
||||
$(`#selection-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "");
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("input", "#selection-tags-filter", function (e) {
|
||||
let filter = $(this).val().toLowerCase();
|
||||
let unfiltered = $("#selection-tags-other > .item-preview");
|
||||
if (filter === "") {
|
||||
unfiltered.css("display", "");
|
||||
return;
|
||||
}
|
||||
unfiltered.each((index, element) => {
|
||||
let current = $(element);
|
||||
if (current.text().toLowerCase().includes(filter)) {
|
||||
current.css("display", "");
|
||||
} else {
|
||||
current.css("display", "none");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("scroll", $("#viewer").contents(), function (e) {
|
||||
let pos = $(this).scrollTop();
|
||||
$(window.parent.document).find(".viewer-nav").css({
|
||||
top: (-pos) + "px",
|
||||
bottom: pos + "px"
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", "#view-next", function (e) {
|
||||
let id = $(this).attr("file_id");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
let curr = $(`.file-preview[file_id='${id}']`)
|
||||
$("#viewer").attr("src", "/files/" + id);
|
||||
$("#view-prev").attr("file_id", curr.prev(":visible").attr("file_id"));
|
||||
$("#view-next").attr("file_id", curr.next(":visible").attr("file_id"));
|
||||
$(".viewer-wrapper").css("display", "");
|
||||
});
|
||||
|
||||
$(document).on("click", "#view-prev", function (e) {
|
||||
let id = $(this).attr("file_id");
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
let curr = $(`.file-preview[file_id='${id}']`)
|
||||
$("#viewer").attr("src", "/files/" + id);
|
||||
$("#view-prev").attr("file_id", curr.prev(":visible").attr("file_id"));
|
||||
$("#view-next").attr("file_id", curr.next(":visible").attr("file_id"));
|
||||
$(".viewer-wrapper").css("display", "");
|
||||
});
|
||||
|
||||
$(document).on("click", "#view-close", function (e) {
|
||||
$(".viewer-wrapper").css("display", "none");
|
||||
});
|
||||
2
web/static/js/jquery-3.6.0.min.js
vendored
Normal file
18
web/static/js/settings.js
Normal file
@ -0,0 +1,18 @@
|
||||
$(window).on("load", function () {
|
||||
$.ajax({
|
||||
url: "/api/get_my_sessions",
|
||||
type: "GET",
|
||||
contentType: "application/json",
|
||||
success: function (resp) {
|
||||
let timezone_offset = new Date().getTimezoneOffset();
|
||||
resp.forEach((session) => {
|
||||
let s_started = beautify_date(session.started);
|
||||
let s_expires = beautify_date(session.expires);
|
||||
$("#sessions-table").append(`<tr><td>${session.user_agent_name}</td><td>${s_started}</td><td>${s_expires === null ? "-" : session.expires}</td><td align="right"><img src="/static/images/icon-terminate.svg" alt="Terminate" class="btn-terminate" session_id="${session.id}"></td></tr>`);
|
||||
});
|
||||
},
|
||||
failure: function (err) {
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
89
web/static/js/tag.js
Normal file
@ -0,0 +1,89 @@
|
||||
$(document).on("input", "#parent-tags-filter", function (e) {
|
||||
let filter = $(this).val().toLowerCase();
|
||||
let unfiltered = $("#parent-tags-other > .tag-preview");
|
||||
if (filter === "") {
|
||||
unfiltered.css("display", "");
|
||||
return;
|
||||
}
|
||||
unfiltered.each((index, element) => {
|
||||
let current = $(element);
|
||||
if (current.text().toLowerCase().includes(filter)) {
|
||||
current.css("display", "");
|
||||
} else {
|
||||
current.css("display", "none");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", "#parent-tags-other > .tag-preview", function (e) {
|
||||
$("#loader").css("display", "");
|
||||
let tag_id = $(this).attr("tag_id");
|
||||
$.ajax({
|
||||
url: location.pathname + "/parent",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({add: true, tag_id: $(this).attr("tag_id")}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
$(`#parent-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
|
||||
$(`#parent-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "");
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", "#parent-tags-selected > .tag-preview", function (e) {
|
||||
$("#loader").css("display", "");
|
||||
let tag_id = $(this).attr("tag_id");
|
||||
$.ajax({
|
||||
url: location.pathname + "/parent",
|
||||
type: "POST",
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({add: false, tag_id: $(this).attr("tag_id")}),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
$(`#parent-tags-selected > .tag-preview[tag_id='${tag_id}']`).css("display", "none");
|
||||
$(`#parent-tags-other > .tag-preview[tag_id='${tag_id}']`).css("display", "");
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("submit", "#object-edit", function (e) {
|
||||
e.preventDefault();
|
||||
$("#loader").css("display", "");
|
||||
$.ajax({
|
||||
url: location.pathname + "/edit",
|
||||
type: "POST",
|
||||
data: $(this).serialize(),
|
||||
dataType: "json",
|
||||
success: function (resp) {
|
||||
$("#loader").css("display", "none");
|
||||
if (resp.status) {
|
||||
location.href = location.pathname.substring(0, location.pathname.lastIndexOf("/"));
|
||||
} else {
|
||||
alert(resp.error);
|
||||
}
|
||||
},
|
||||
failure: function (err) {
|
||||
$("#loader").css("display", "none");
|
||||
alert(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
11
web/static/service/browserconfig.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square70x70logo src="/images/ms-icon-70x70.png" />
|
||||
<square150x150logo src="/images/ms-icon-150x150.png" />
|
||||
<square310x310logo src="/images/ms-icon-310x310.png" />
|
||||
<TileColor>#615880</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
BIN
web/static/service/favicon.ico
Normal file
|
After Width: | Height: | Size: 20 KiB |
2
web/static/service/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
49
web/static/service/tanabata.webmanifest
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Tanabata File Manager",
|
||||
"short_name": "Tanabata",
|
||||
"lang": "en-US",
|
||||
"description": "Tanabata File Manager PWA",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#615880",
|
||||
"background_color": "#312F45",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/images\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/images\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/images\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/images\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/images\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/images\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
25
web/templates/auth.html
Normal file
@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'head.html' %}
|
||||
<title>Welcome to Tanabata File Manager!</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/auth.css') }}">
|
||||
</head>
|
||||
<body data-bs-theme="dark">
|
||||
<img src="{{ url_for('static', filename='images/tanabata-left.png') }}" alt="" class="decoration left">
|
||||
<img src="{{ url_for('static', filename='images/tanabata-right.png') }}" alt="" class="decoration right">
|
||||
<form id="auth">
|
||||
<h1>Welcome to Tanabata File Manager!</h1>
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control form-control-lg" id="username" name="username" placeholder="Username..." required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" class="form-control form-control-lg" id="password" name="password" placeholder="Password..." required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary" id="login">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
<script src="{{ url_for('static', filename='js/auth.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
web/templates/head.html
Normal file
@ -0,0 +1,29 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="{{ url_for('static', filename='images/apple-icon-57x57.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="{{ url_for('static', filename='images/apple-icon-60x60.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="{{ url_for('static', filename='images/apple-icon-72x72.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="{{ url_for('static', filename='images/apple-icon-76x76.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="{{ url_for('static', filename='images/apple-icon-114x114.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="{{ url_for('static', filename='images/apple-icon-120x120.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="{{ url_for('static', filename='images/apple-icon-144x144.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="{{ url_for('static', filename='images/apple-icon-152x152.png') }}">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-icon-180x180.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="{{ url_for('static', filename='images/android-icon-192x192.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="{{ url_for('static', filename='images/favicon-96x96.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
|
||||
<link rel="manifest" href="/tanabata.webmanifest">
|
||||
<meta name="msapplication-TileColor" content="#615880">
|
||||
<meta name="msapplication-TileImage" content="{{ url_for('static', filename='images/ms-icon-144x144.png') }}">
|
||||
<meta name="theme-color" content="#615880">
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: Epilogue;
|
||||
src: url({{ url_for('static', filename='fonts/Epilogue-VariableFont_wght.ttf') }});
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/general.css') }}">
|
||||
<script src="{{ url_for('static', filename='js/jquery-3.6.0.min.js') }}"></script>
|
||||
43
web/templates/new-category.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'head.html' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
|
||||
<title>New category | Tanabata File Manager</title>
|
||||
</head>
|
||||
<body data-bs-theme="dark">
|
||||
<header>
|
||||
<h1><img src="{{ url_for('static', filename='images/icon-category.svg') }}" alt="Category -" class="icon-header"> New category</h1>
|
||||
<form id="object-add" method="POST" action="/categories/new">
|
||||
<div class="row">
|
||||
<div class="form-group col-md-10">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="name" required>
|
||||
</div>
|
||||
<div class="form-group col-md-2">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" class="form-control form-control-color" name="color" id="color" value="#444455">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea class="form-control" name="notes" id="notes" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="is_private" class="form-check-label">Is private</label>
|
||||
<input type="checkbox" name="is_private" id="is_private" class="form-check-input" checked>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Add category</button>
|
||||
</div>
|
||||
</form>
|
||||
</header>
|
||||
<div id="loader" style="display: none">
|
||||
<div class="loader-wrapper">
|
||||
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/add-category.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
82
web/templates/new-file.html
Normal file
@ -0,0 +1,82 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'head.html' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
|
||||
<title>New file | Tanabata File Manager</title>
|
||||
<style>
|
||||
.tags-container {
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
height: 200px;
|
||||
background-color: #212529;
|
||||
border: 1px solid #495057;
|
||||
border-radius: .375rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.tags-container:after {
|
||||
content: "";
|
||||
flex: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body data-bs-theme="dark">
|
||||
<div class="file">
|
||||
<a href="/static/files/{{ file['id'] }}.{{ file['extension'] }}" class="preview-link" target="_blank">
|
||||
<img src="/static/previews/{{ file['id'] }}.{{ file['extension'] }}" alt="{{ file['id'] }}.{{ file['extension'] }}" class="preview-img">
|
||||
</a>
|
||||
</div>
|
||||
<header>
|
||||
<form id="object-edit">
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea class="form-control" name="init-notes" rows="3" hidden>{{ file['notes'] }}</textarea>
|
||||
<textarea class="form-control" id="notes" name="notes" rows="3">{{ file['notes'] }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="datetime">Datetime</label>
|
||||
<input type="datetime-local" class="form-control" name="init-datetime" step="1" value="{{ file['datetime'] }}" hidden>
|
||||
<input type="datetime-local" class="form-control" id="datetime" name="datetime" step="1" value="{{ file['datetime'] }}" required>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="is-private" class="form-check-label">Is private</label>
|
||||
<input type="checkbox" class="form-check-input" name="init-is_private" {% if file['is_private'] %}checked{% endif %} hidden>
|
||||
<input type="checkbox" class="form-check-input" name="is_private" {% if file['is_private'] %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="file-tags">
|
||||
<div class="tags-container tags-container-selected" id="file-tags-selected">
|
||||
{% for tag in tags %}
|
||||
<div class="tag-preview" tag_id="{{ tag['id'] }}"{% if tag['color'] %} style="background-color: #{{ tag['color'] }}"{% elif tag['category_color'] %} style="background-color: #{{ tag['category_color'] }}"{% endif %}>{{ tag['name'] }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="text" class="form-control" id="file-tags-filter" placeholder="Filter tags...">
|
||||
<div class="tags-container" id="file-tags-other">
|
||||
{% for tag in tags_all %}
|
||||
{% if tag not in tags %}
|
||||
<div class="tag-preview" tag_id="{{ tag['id'] }}"{% if tag['color'] %} style="background-color: #{{ tag['color'] }}"{% elif tag['category_color'] %} style="background-color: #{{ tag['category_color'] }}"{% endif %}>{{ tag['name'] }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="loader" style="display: none">
|
||||
<div class="loader-wrapper">
|
||||
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/file.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
52
web/templates/new-tag.html
Normal file
@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'head.html' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
|
||||
<title>New tag | Tanabata File Manager</title>
|
||||
</head>
|
||||
<body data-bs-theme="dark">
|
||||
<header>
|
||||
<h1><img src="{{ url_for('static', filename='images/icon-tag.svg') }}" alt="Tag -" class="icon-header"> New tag</h1>
|
||||
<form id="object-add" method="POST" action="/tags/new">
|
||||
<div class="row">
|
||||
<div class="form-group col-md-10">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="name" required>
|
||||
</div>
|
||||
<div class="form-group col-md-2">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" class="form-control form-control-color" name="color" id="color" value="#444455">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea class="form-control" name="notes" id="notes" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category">Category</label>
|
||||
<select name="category_id" class="form-control" id="category">
|
||||
<option></option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category['id'] }}">{{ category['name'] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="is_private" class="form-check-label">Is private</label>
|
||||
<input type="checkbox" name="is_private" id="is_private" class="form-check-input" checked>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Add tag</button>
|
||||
</div>
|
||||
</form>
|
||||
</header>
|
||||
<div id="loader" style="display: none">
|
||||
<div class="loader-wrapper">
|
||||
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/add-tag.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
15
web/templates/section-categories.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends 'section.html' %}
|
||||
{% set section = 'categories' %}
|
||||
{% set sorting_options = ['name', 'color', 'created'] %}
|
||||
|
||||
{% block Header %}
|
||||
<div class="filtering-wrapper">
|
||||
<input type="text" class="form-control filtering" id="filter" placeholder="Filter {{ section }}...">
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block Main %}
|
||||
{% for category in categories %}
|
||||
<div class="item-preview category-preview" category_id="{{ category['id'] }}"{% if category['color'] %} style="background-color: #{{ category['color'] }}"{% endif %}>{{ category['name'] }}</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
33
web/templates/section-files.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% extends 'section.html' %}
|
||||
{% set section = 'files' %}
|
||||
{% set sorting_options = ['mime_name', 'datetime', 'created'] %}
|
||||
|
||||
{% block Header %}
|
||||
<div class="filtering-wrapper">
|
||||
<div class="filtering-tokens" id="files-filter"></div>
|
||||
</div>
|
||||
<div class="filtering-block" style="display: none">
|
||||
<div class="filtering-operators">
|
||||
<div class="filtering-token" val="("><i>(</i></div>
|
||||
<div class="filtering-token" val="&"><i>AND</i></div>
|
||||
<div class="filtering-token" val="!"><i>NOT</i></div>
|
||||
<div class="filtering-token" val="|"><i>OR</i></div>
|
||||
<div class="filtering-token" val=")"><i>)</i></div>
|
||||
</div>
|
||||
<div class="filtering-filter">
|
||||
<input type="text" class="form-control filtering" id="filter-filtering" placeholder="Filter tags...">
|
||||
</div>
|
||||
<div class="filtering-tokens" id="filtering-tokens-all">
|
||||
<div class="filtering-token" val="t=00000000-0000-0000-0000-000000000000"><i>Untagged</i></div>
|
||||
<div class="filtering-token" val="m~image%"><i>MIME: image/*</i></div>
|
||||
<div class="filtering-token" val="m~video%"><i>MIME: video/*</i></div>
|
||||
</div>
|
||||
<div class="form-group btn-row">
|
||||
<button class="btn btn-primary" id="filtering-apply" style="width: 48%;">Apply</button>
|
||||
<button class="btn btn-danger" id="filtering-reset" style="width: 48%;">Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block Main %}
|
||||
{% endblock %}
|
||||
35
web/templates/section-settings.html
Normal file
@ -0,0 +1,35 @@
|
||||
{% extends 'section.html' %}
|
||||
{% set section = 'settings' %}
|
||||
|
||||
{% block Main %}
|
||||
<form id="settings-user">
|
||||
<h2>User settings</h2>
|
||||
<div class="row">
|
||||
<div class="form-group col-md-6">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" class="form-control" name="username" id="username" value="{{ 'aboba' }}" required>
|
||||
</div>
|
||||
<div class="form-group col-md-6">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" class="form-control" name="password" id="password">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 14px">
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="sessions-wrapper">
|
||||
<h2>Sessions</h2>
|
||||
<table class="table table-dark table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User agent</th>
|
||||
<th>Started</th>
|
||||
<th>Expires</th>
|
||||
<th>Terminate</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sessions-table"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
15
web/templates/section-tags.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% extends 'section.html' %}
|
||||
{% set section = 'tags' %}
|
||||
{% set sorting_options = ['name', 'color', 'category_name', 'created'] %}
|
||||
|
||||
{% block Header %}
|
||||
<div class="filtering-wrapper">
|
||||
<input type="text" class="form-control filtering" id="filter" placeholder="Filter {{ section }}...">
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block Main %}
|
||||
{% for tag in tags %}
|
||||
<div class="item-preview tag-preview" tag_id="{{ tag['id'] }}"{% if tag['color'] %} style="background-color: #{{ tag['color'] }}"{% elif tag['category_color'] %} style="background-color: #{{ tag['category_color'] }}"{% endif %}>{{ tag['name'] }}</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
117
web/templates/section.html
Normal file
@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'head.html' %}
|
||||
<title>{{ section[0]|upper() }}{{ section[1:] }} | Tanabata File Manager</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
|
||||
{% if section == 'files' %}
|
||||
<!-- <script src="{{ url_for('static', filename='js/jquery.lazy.min.js') }}"></script> -->
|
||||
{% endif %}
|
||||
</head>
|
||||
<body data-bs-theme="dark">
|
||||
{% if section != 'settings' %}
|
||||
<header>
|
||||
<div class="sorting">
|
||||
<div class="highlighted" id="select">Select</div>
|
||||
<div id="sorting">Sorting by <span class="highlighted" id="attribute">{{ sorting['key'] }} ({% if sorting['asc'] %}asc{% else %}desc{% endif %})</span> <img src="{{ url_for('static', filename='images/icon-expand.svg') }}" alt="" id="icon-expand"></div>
|
||||
<form id="sorting-options" style="display: none">
|
||||
{% for opt in sorting_options %}
|
||||
<div class="form-check sorting-option">
|
||||
<label class="form-check-label" for="{{ opt }}">{{ opt }}</label>
|
||||
<input type="radio" class="form-check-input" name="sorting" value="{{ opt }}" id="{{ opt }}" {% if opt == sorting['key'] %}checked prev-checked{% endif %}>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<hr>
|
||||
<div class="form-check sorting-option">
|
||||
<label class="form-check-label" for="asc">ascending</label>
|
||||
<input type="radio" class="form-check-input" name="order" value="asc" id="asc" {% if sorting['asc'] %}checked prev-checked{% endif %}>
|
||||
</div>
|
||||
<div class="form-check sorting-option">
|
||||
<label class="form-check-label" for="desc">descending</label>
|
||||
<input type="radio" class="form-check-input" name="order" value="desc" id="desc" {% if not sorting['asc'] %}checked prev-checked{% endif %}>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% block Header %}{% endblock %}
|
||||
</header>
|
||||
{% endif %}
|
||||
<main>
|
||||
{% block Main %}{% endblock %}
|
||||
</main>
|
||||
{% if section != 'settings' %}
|
||||
<div class="selection-manager" style="display: none">
|
||||
<div class="selection-header">
|
||||
<div id="selection-info"><span id="selection-count">0</span> selected</div>
|
||||
{% if section == 'files' %}
|
||||
<div id="selection-edit-tags">Edit tags</div>
|
||||
<div id="selection-add-to-pool">Add to pool</div>
|
||||
{% endif %}
|
||||
<div id="selection-delete">Delete</div>
|
||||
</div>
|
||||
<hr>
|
||||
{% if section == 'files' %}
|
||||
<div class="selection-tags" style="display: none">
|
||||
<div class="tags-container tags-container-selected" id="selection-tags-selected">
|
||||
{% for tag in tags_all %}
|
||||
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %};display: none">{{ tag['name'] }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="text" class="form-control filtering" id="selection-tags-filter" placeholder="Filter tags...">
|
||||
<div class="tags-container" id="selection-tags-other">
|
||||
{% for tag in tags_all %}
|
||||
<div class="item-preview tag-preview" tag_id="{{ tag['id'] }}"{% if tag['color'] %} style="background-color: #{{ tag['color'] }}"{% elif tag['category_color'] %} style="background-color: #{{ tag['category_color'] }}"{% endif %}>{{ tag['name'] }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if section == 'files' %}
|
||||
<div class="viewer-wrapper" style="display: none">
|
||||
<div class="viewer-nav viewer-nav-close" id="view-close">
|
||||
<div class="viewer-nav-icon" style="background-image: url({{ url_for('static', filename='images/layer-controls.png') }}); background-position: -3px 0;"></div>
|
||||
</div>
|
||||
<div class="viewer-nav viewer-nav-prev" id="view-prev">
|
||||
<div class="viewer-nav-icon" style="background-image: url({{ url_for('static', filename='images/layer-controls.png') }}); background-position: 0-25px;"></div>
|
||||
</div>
|
||||
<div class="viewer-nav viewer-nav-next" id="view-next">
|
||||
<div class="viewer-nav-icon" style="background-image: url({{ url_for('static', filename='images/layer-controls.png') }}); background-position: 0-63px;"></div>
|
||||
</div>
|
||||
<iframe src="" frameborder="0" id="viewer"></iframe>
|
||||
</div>
|
||||
{% endif %}
|
||||
<footer>
|
||||
{% if section == 'categories' %}
|
||||
<a href="/categories/new" class="nav curr"><img src="{{ url_for('static', filename='images/icon-add.svg') }}" alt="Add category" class="navicon"></a>
|
||||
{% else %}
|
||||
<a href="/categories" class="nav"><img src="{{ url_for('static', filename='images/icon-category.svg') }}" alt="Categories" class="navicon"></a>
|
||||
{% endif %}
|
||||
|
||||
{% if section == 'tags' %}
|
||||
<a href="/tags/new" class="nav curr"><img src="{{ url_for('static', filename='images/icon-add.svg') }}" alt="Add tag" class="navicon"></a>
|
||||
{% else %}
|
||||
<a href="/tags" class="nav"><img src="{{ url_for('static', filename='images/icon-tag.svg') }}" alt="Tags" class="navicon"></a>
|
||||
{% endif %}
|
||||
|
||||
{% if section == 'files' %}
|
||||
<a href="/files/new" class="nav curr"><img src="{{ url_for('static', filename='images/icon-add.svg') }}" alt="Add file" class="navicon"></a>
|
||||
{% else %}
|
||||
<a href="/files" class="nav"><img src="{{ url_for('static', filename='images/icon-file.svg') }}" alt="Files" class="navicon"></a>
|
||||
{% endif %}
|
||||
|
||||
{% if section == 'pools' %}
|
||||
<a href="/pools/new" class="nav curr"><img src="{{ url_for('static', filename='images/icon-add.svg') }}" alt="Add pool" class="navicon"></a>
|
||||
{% else %}
|
||||
<a href="/pools" class="nav"><img src="{{ url_for('static', filename='images/icon-pool.svg') }}" alt="Pools" class="navicon"></a>
|
||||
{% endif %}
|
||||
|
||||
{% if section == 'settings' %}
|
||||
<a href="/settings" class="nav curr"><img src="{{ url_for('static', filename='images/icon-settings.svg') }}" alt="Settings" class="navicon"></a>
|
||||
{% else %}
|
||||
<a href="/settings" class="nav"><img src="{{ url_for('static', filename='images/icon-settings.svg') }}" alt="Settings" class="navicon"></a>
|
||||
{% endif %}
|
||||
</footer>
|
||||
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/'+ section + '.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
web/templates/view-category.html
Normal file
@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'head.html' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
|
||||
<title>Category - {{ category['name'] }} | Tanabata File Manager</title>
|
||||
</head>
|
||||
<body data-bs-theme="dark">
|
||||
<header>
|
||||
<h1><img src="{{ url_for('static', filename='images/icon-category.svg') }}" alt="Category -" class="icon-header"> {{ category['name'] }}</h1>
|
||||
<form id="object-edit">
|
||||
<div class="row">
|
||||
<div class="form-group col-md-10">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="name" value="{{ category['name'] }}" required>
|
||||
</div>
|
||||
<div class="form-group col-md-2">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" class="form-control form-control-color" name="color" id="color" value="#{% if category['color'] %}{{ category['color'] }}{% else %}444455{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea class="form-control" name="notes" id="notes" rows="3">{{ category['notes'] }}</textarea>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="is_private" class="form-check-label">Is private</label>
|
||||
<input type="checkbox" name="is_private" id="is_private" class="form-check-input" {% if category['is_private'] %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</header>
|
||||
<div id="loader" style="display: none">
|
||||
<div class="loader-wrapper">
|
||||
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/category.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
57
web/templates/view-file.html
Normal file
@ -0,0 +1,57 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'head.html' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
|
||||
<title>File - {{ file['id'] }}.{{ file['extension'] }} | Tanabata File Manager</title>
|
||||
</head>
|
||||
<body data-bs-theme="dark">
|
||||
<div class="file">
|
||||
<a href="/static/files/{{ file['id'] }}" class="preview-link" target="_blank">
|
||||
<img src="/static/previews/{{ file['id'] }}" alt="{{ file['id'] }}" class="preview-img">
|
||||
</a>
|
||||
</div>
|
||||
<header>
|
||||
<form id="object-edit">
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea class="form-control" name="init-notes" rows="3" hidden>{{ file['notes'] }}</textarea>
|
||||
<textarea class="form-control" id="notes" name="notes" rows="3">{{ file['notes'] }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="datetime">Datetime</label>
|
||||
<input type="datetime-local" class="form-control" name="init-datetime" step="1" value="{{ file['datetime'] }}" hidden>
|
||||
<input type="datetime-local" class="form-control" id="datetime" name="datetime" step="1" value="{{ file['datetime'] }}" required>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="is_private" class="form-check-label">Is private</label>
|
||||
<input type="checkbox" class="form-check-input" name="init-is_private" {% if file['is_private'] %}checked{% endif %} hidden>
|
||||
<input type="checkbox" class="form-check-input" id="is_private" name="is_private" {% if file['is_private'] %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="file-tags">
|
||||
<div class="tags-container" id="file-tags-selected">
|
||||
{% for tag in tags_all %}
|
||||
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %}{% if tag not in tags %}display: none;{% endif %}">{{ tag['name'] }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="text" class="form-control filtering" id="file-tags-filter" placeholder="Filter tags...">
|
||||
<div class="tags-container" id="file-tags-other">
|
||||
{% for tag in tags_all %}
|
||||
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %}{% if tag in tags %}display: none;{% endif %}">{{ tag['name'] }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="loader" style="display: none">
|
||||
<div class="loader-wrapper">
|
||||
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/file.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
65
web/templates/view-tag.html
Normal file
@ -0,0 +1,65 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
{% include 'head.html' %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/interface.css') }}">
|
||||
<title>Tag - {{ tag['name'] }} | Tanabata File Manager</title>
|
||||
</head>
|
||||
<body data-bs-theme="dark">
|
||||
<header>
|
||||
<h1><img src="{{ url_for('static', filename='images/icon-tag.svg') }}" alt="Tag -" class="icon-header"> {{ tag['name'] }}</h1>
|
||||
<form id="object-edit">
|
||||
<div class="row">
|
||||
<div class="form-group col-md-10">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="name" value="{{ tag['name'] }}" required>
|
||||
</div>
|
||||
<div class="form-group col-md-2">
|
||||
<label for="color">Color</label>
|
||||
<input type="color" class="form-control form-control-color" name="color" id="color" value="#{% if tag['color'] %}{{ tag['color'] }}{% else %}444455{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea class="form-control" name="notes" id="notes" rows="3">{{ tag['notes'] }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="category">Category</label>
|
||||
<select name="category_id" class="form-control" id="category">
|
||||
<option value="00000000-0000-0000-0000-000000000000"></option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category['id'] }}" {% if category['id'] == tag['category_id'] %}selected{% endif %}>{{ category['name'] }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<label for="is_private" class="form-check-label">Is private</label>
|
||||
<input type="checkbox" name="is_private" id="is_private" class="form-check-input" {% if tag['is_private'] %}checked{% endif %}>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="parent-tags">
|
||||
<div class="tags-container tags-container-selected" id="parent-tags-selected">
|
||||
{% for tag in tags_all %}
|
||||
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %}{% if tag not in parent_tags %}display: none;{% endif %}">{{ tag['name'] }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<input type="text" class="form-control filtering" id="parent-tags-filter" placeholder="Filter tags...">
|
||||
<div class="tags-container" id="parent-tags-other">
|
||||
{% for tag in tags_all %}
|
||||
<div class="tag-preview" tag_id="{{ tag['id'] }}" style="{% if tag['color'] %}background-color: #{{ tag['color'] }};{% elif tag['category_color'] %}background-color: #{{ tag['category_color'] }};{% endif %}{% if tag in parent_tags %}display: none;{% endif %}">{{ tag['name'] }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="loader" style="display: none">
|
||||
<div class="loader-wrapper">
|
||||
<img src="{{ url_for('static', filename='images/loader.gif') }}" alt="Loading..." class="loader-img">
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ url_for('static', filename='js/interface.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/tag.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
476
web/tfm_web.py
Normal file
@ -0,0 +1,476 @@
|
||||
#!../venv/bin/python3
|
||||
|
||||
from flask import Flask, render_template, request, session, jsonify, redirect, url_for, send_from_directory, send_file, abort
|
||||
from flask_cors import CORS
|
||||
from ua_parser.user_agent_parser import ParseUserAgent
|
||||
import sys
|
||||
from os.path import dirname, abspath, join
|
||||
|
||||
sys.path.append(dirname(dirname(abspath(__file__))))
|
||||
import api.tfm_api as tfm_api
|
||||
|
||||
tfm_api.Initialize()
|
||||
app = Flask("TFM")
|
||||
CORS(app)
|
||||
app.secret_key = tfm_api.conf["Flask"]["SecretKey"]
|
||||
|
||||
|
||||
@app.route("/api/<func>", methods=["GET"])
|
||||
def api(func):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
return redirect("/")
|
||||
try:
|
||||
if func == "files":
|
||||
sorting = session.get("sorting")["files"]
|
||||
philter = request.args.get("filter")
|
||||
offset = request.args.get("offset", type=int, default=0)
|
||||
limit = request.args.get("limit", type=int)
|
||||
return jsonify(ts.get_files_by_filter(philter, sorting["key"], sorting["asc"], offset, limit))
|
||||
if func == "tags":
|
||||
sorting = session.get("sorting")["tags"]
|
||||
offset = request.args.get("offset", type=int, default=0)
|
||||
limit = request.args.get("limit", type=int)
|
||||
return jsonify(ts.get_tags(sorting["key"], sorting["asc"], offset, limit))
|
||||
if func == "get_my_sessions":
|
||||
offset = request.args.get("offset", type=int, default=0)
|
||||
limit = request.args.get("limit", type=int)
|
||||
return jsonify(ts.get_my_sessions(offset=offset, limit=limit))
|
||||
if func == "terminate_session":
|
||||
session_id = request.args.get("id");
|
||||
if session_id is None:
|
||||
session_id = ts.sid
|
||||
return jsonify(), 204
|
||||
abort(400)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
abort(500, str(e))
|
||||
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
@app.route("/robots.txt")
|
||||
@app.route("/tanabata.webmanifest")
|
||||
@app.route("/browserconfig.xml")
|
||||
def favicon():
|
||||
return send_from_directory(join(app.root_path, "static/service"), request.path[1:])
|
||||
|
||||
|
||||
@app.route("/", methods=["GET"])
|
||||
def index():
|
||||
if session.get("id"):
|
||||
return redirect("/files")
|
||||
return render_template("auth.html")
|
||||
|
||||
|
||||
@app.route("/auth", methods=["POST"])
|
||||
def auth():
|
||||
try:
|
||||
ts = tfm_api.authorize(
|
||||
request.form.get("username"),
|
||||
request.form.get("password"),
|
||||
ParseUserAgent(request.headers.get("User-Agent"))["family"]
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
else:
|
||||
logout()
|
||||
session["id"] = ts.sid
|
||||
session["sorting"] = tfm_api.DEFAULT_SORTING
|
||||
session.permanent = True
|
||||
session.modified = True
|
||||
return jsonify({"status": True, "is_admin": ts.is_admin})
|
||||
|
||||
|
||||
@app.route("/logout", methods=["GET"])
|
||||
def logout():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
ts.terminate()
|
||||
finally:
|
||||
session.clear()
|
||||
return redirect("/")
|
||||
|
||||
|
||||
@app.route("/files", methods=["GET"])
|
||||
def files():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
sorting = session.get("sorting")["files"]
|
||||
sorting_t = session.get("sorting")["tags"]
|
||||
except Exception as e:
|
||||
logout()
|
||||
return redirect("/")
|
||||
return render_template("section-files.html",
|
||||
files=ts.get_files(sorting["key"], sorting["asc"], limit=100),
|
||||
sorting=sorting,
|
||||
tags_all=ts.get_tags(sorting_t["key"], sorting_t["asc"])
|
||||
)
|
||||
|
||||
|
||||
@app.route("/tags", methods=["GET"])
|
||||
def tags():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
sorting = session.get("sorting")["tags"]
|
||||
except Exception as e:
|
||||
logout()
|
||||
return redirect("/")
|
||||
return render_template("section-tags.html",
|
||||
tags=ts.get_tags(sorting["key"], sorting["asc"]),
|
||||
sorting=sorting
|
||||
)
|
||||
|
||||
|
||||
@app.route("/categories", methods=["GET"])
|
||||
def categories():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
sorting = session.get("sorting")["categories"]
|
||||
except Exception as e:
|
||||
logout()
|
||||
return redirect("/")
|
||||
return render_template("section-categories.html",
|
||||
categories=ts.get_categories(sorting["key"], sorting["asc"]),
|
||||
sorting=sorting
|
||||
)
|
||||
|
||||
|
||||
@app.route("/settings", methods=["GET"])
|
||||
def settings():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
return redirect("/")
|
||||
return render_template("section-settings.html")
|
||||
|
||||
|
||||
@app.route("/files/<file_id>", methods=["GET"])
|
||||
def file(file_id):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
return redirect("/")
|
||||
try:
|
||||
file = ts.get_file(file_id)
|
||||
if not file:
|
||||
abort(404, "File does not exist")
|
||||
file["datetime"] = file["datetime"].strftime('%Y-%m-%dT%H:%M:%S')
|
||||
sorting = session.get("sorting")["tags"]
|
||||
ts.view_file(file_id)
|
||||
return render_template("view-file.html",
|
||||
file=file,
|
||||
tags=ts.get_tags_by_file(file_id, sorting["key"], sorting["asc"]),
|
||||
tags_all=ts.get_tags(sorting["key"], sorting["asc"])
|
||||
)
|
||||
except Exception as e:
|
||||
abort(400, str(e).split('\n')[0])
|
||||
|
||||
|
||||
@app.route("/tags/<tag_id>", methods=["GET", "POST"])
|
||||
def tag(tag_id):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
if request.method == "POST":
|
||||
abort(401)
|
||||
return redirect("/")
|
||||
try:
|
||||
tag = ts.get_tag(tag_id)
|
||||
if not tag:
|
||||
raise RuntimeError("Tag does not exist")
|
||||
sorting_c = session.get("sorting")["categories"]
|
||||
sorting_t = session.get("sorting")["tags"]
|
||||
return render_template("view-tag.html",
|
||||
tag=tag,
|
||||
categories=ts.get_categories(sorting_c["key"], sorting_c["asc"]),
|
||||
parent_tags=ts.get_parent_tags(tag_id),
|
||||
tags_all=ts.get_tags(sorting_t["key"], sorting_t["asc"])
|
||||
)
|
||||
except Exception as e:
|
||||
abort(400, str(e).split('\n')[0])
|
||||
|
||||
|
||||
@app.route("/categories/<category_id>", methods=["GET", "POST"])
|
||||
def category(category_id):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
if request.method == "POST":
|
||||
abort(401)
|
||||
return redirect("/")
|
||||
try:
|
||||
category = ts.get_category(category_id)
|
||||
if not category:
|
||||
raise RuntimeError("Category does not exist")
|
||||
return render_template("view-category.html",
|
||||
category=category
|
||||
)
|
||||
except Exception as e:
|
||||
abort(400, str(e).split('\n')[0])
|
||||
|
||||
|
||||
@app.route("/tags/new", methods=["GET", "POST"])
|
||||
def new_tag():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
if request.method == "POST":
|
||||
abort(401)
|
||||
return redirect("/")
|
||||
if request.method == "POST":
|
||||
try:
|
||||
color = request.form.get("color")
|
||||
if color == "#444455":
|
||||
color = None
|
||||
return jsonify({"status": True, "tag_id": ts.add_tag(request.form.get("name").strip(),
|
||||
request.form.get("notes"),
|
||||
color,
|
||||
request.form.get("category_id"),
|
||||
request.form.get("is_private", False))})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
try:
|
||||
sorting = session.get("sorting")["categories"]
|
||||
return render_template("new-tag.html",
|
||||
categories=ts.get_categories(sorting["key"], sorting["asc"])
|
||||
)
|
||||
except Exception as e:
|
||||
abort(400, str(e).split('\n')[0])
|
||||
|
||||
|
||||
@app.route("/categories/new", methods=["GET", "POST"])
|
||||
def new_category():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
if request.method == "POST":
|
||||
abort(401)
|
||||
return redirect("/")
|
||||
if request.method == "POST":
|
||||
try:
|
||||
color = request.form.get("color")
|
||||
if color == "#444455":
|
||||
color = None
|
||||
return jsonify({"status": True, "tag_id": ts.add_category(request.form.get("name").strip(),
|
||||
request.form.get("notes"),
|
||||
color,
|
||||
request.form.get("is_private", False))})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
try:
|
||||
return render_template("new-category.html")
|
||||
except Exception as e:
|
||||
abort(400, str(e).split('\n')[0])
|
||||
|
||||
|
||||
@app.route("/files/<file_id>/edit", methods=["POST"])
|
||||
def edit_file(file_id):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(401)
|
||||
try:
|
||||
ts.edit_file(file_id, None, request.form.get("datetime"), request.form.get("notes"), request.form.get("is_private", False))
|
||||
return jsonify({"status": True})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
|
||||
|
||||
@app.route("/tags/<tag_id>/edit", methods=["POST"])
|
||||
def edit_tag(tag_id):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(401)
|
||||
try:
|
||||
color = request.form.get("color")
|
||||
if color == "#444455":
|
||||
color = ""
|
||||
ts.edit_tag(tag_id, request.form.get("name").strip(), request.form.get("notes"), color, request.form.get("category_id"), request.form.get("is_private", False))
|
||||
return jsonify({"status": True})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
|
||||
|
||||
@app.route("/categories/<category_id>/edit", methods=["POST"])
|
||||
def edit_category(category_id):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(401)
|
||||
try:
|
||||
color = request.form.get("color")
|
||||
if color == "#444455":
|
||||
color = ""
|
||||
ts.edit_category(category_id, request.form.get("name").strip(), request.form.get("notes"), color, request.form.get("is_private", False))
|
||||
return jsonify({"status": True})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
|
||||
|
||||
@app.route("/files/<file_id>/tag", methods=["POST"])
|
||||
def file_tags(file_id):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(401)
|
||||
try:
|
||||
req = request.get_json()
|
||||
if req["add"]:
|
||||
return jsonify({"status": True, "tags": ts.add_file_to_tag(file_id, req["tag_id"])})
|
||||
else:
|
||||
ts.remove_file_to_tag(file_id, req["tag_id"])
|
||||
return jsonify({"status": True})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
|
||||
|
||||
@app.route("/tags/<tag_id>/parent", methods=["POST"])
|
||||
def parent_tags(tag_id):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(401)
|
||||
try:
|
||||
req = request.get_json()
|
||||
if req["add"]:
|
||||
ts.add_autotag(tag_id, req["tag_id"])
|
||||
return jsonify({"status": True})
|
||||
else:
|
||||
ts.remove_autotag(tag_id, req["tag_id"])
|
||||
return jsonify({"status": True})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
|
||||
|
||||
@app.route("/files/tags", methods=["POST"])
|
||||
def files_tags():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(401)
|
||||
try:
|
||||
req = request.get_json()
|
||||
if req["action"] == "get":
|
||||
res = set(map(lambda t: t["id"], ts.get_tags_by_file(req["file_id_list"][0])))
|
||||
for file_id in req["file_id_list"][1:]:
|
||||
res &= set(map(lambda t: t["id"], ts.get_tags_by_file(file_id)))
|
||||
return jsonify({"status": True, "tag_id_list": list(res)})
|
||||
elif req["action"] == "add":
|
||||
res = set()
|
||||
for file_id in req["file_id_list"]:
|
||||
res |= set(ts.add_file_to_tag(file_id, req["tag_id"]))
|
||||
return jsonify({"status": True, "tag_id_list": list(res)})
|
||||
elif req["action"] == "remove":
|
||||
for file_id in req["file_id_list"]:
|
||||
ts.remove_file_to_tag(file_id, req["tag_id"])
|
||||
return jsonify({"status": True})
|
||||
else:
|
||||
return jsonify({"status": False, "error": "unsupported action"})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
|
||||
|
||||
@app.route("/files/delete", methods=["POST"])
|
||||
def files_delete():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(401)
|
||||
try:
|
||||
req = request.get_json()
|
||||
for file_id in req["file_id_list"]:
|
||||
ts.remove_file(file_id)
|
||||
return jsonify({"status": True})
|
||||
except Exception as e:
|
||||
return jsonify({"status": False, "error": str(e).split('\n')[0]})
|
||||
|
||||
|
||||
@app.route("/static/files/<file_id>", methods=["GET"])
|
||||
def file_full(file_id=None):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(404)
|
||||
try:
|
||||
file = ts.get_file(file_id)
|
||||
if not file:
|
||||
raise RuntimeError("File does not exist")
|
||||
return send_file(
|
||||
join(tfm_api.conf["Paths"]["Files"], file_id),
|
||||
mimetype=file["mime_name"],
|
||||
download_name=(file["orig_name"] if file["orig_name"] else "%s.%s" % (file_id, file["extension"]))
|
||||
)
|
||||
except:
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route("/static/thumbs/<file_id>", methods=["GET"])
|
||||
def thumb(file_id=None):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(404)
|
||||
try:
|
||||
file = ts.get_file(file_id)
|
||||
if not file:
|
||||
raise RuntimeError("File does not exist")
|
||||
return send_file(
|
||||
tfm_api.previewer.get_jpeg_preview(join(tfm_api.conf["Paths"]["Files"], file_id), height=160, width=160),
|
||||
mimetype="image/jpeg"
|
||||
)
|
||||
except:
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route("/static/previews/<file_id>", methods=["GET"])
|
||||
def preview(file_id=None):
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
abort(404)
|
||||
try:
|
||||
file = ts.get_file(file_id)
|
||||
if not file:
|
||||
raise RuntimeError("File does not exist")
|
||||
return send_file(
|
||||
tfm_api.previewer.get_jpeg_preview(join(tfm_api.conf["Paths"]["Files"], file_id), height=1080, width=1920),
|
||||
mimetype="image/jpeg"
|
||||
)
|
||||
except:
|
||||
abort(404)
|
||||
|
||||
|
||||
@app.route("/settings/sorting", methods=["POST"])
|
||||
def sorting():
|
||||
try:
|
||||
ts = tfm_api.TSession(session.get("id"))
|
||||
except Exception as e:
|
||||
logout()
|
||||
return redirect("/")
|
||||
req = request.get_json()
|
||||
session["sorting"].update(req)
|
||||
session.modified = True
|
||||
return jsonify({"status": True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host=tfm_api.conf["Flask"]["Host"], port=tfm_api.conf["Flask"]["Port"], debug=True)
|
||||