Compare commits

...
Sign in to create a new pull request.

21 commits
main ... svelte

Author SHA1 Message Date
da9b879116 frontend: add input class to calendar 2025-10-14 00:10:54 +03:00
5535ffd048 frontend: implement dynamic file list 2025-10-14 00:06:47 +03:00
09c0f12692 frontend: containers refactor 2025-10-13 21:54:30 +03:00
5be65229f4 frontend: initial file component 2025-10-13 17:26:38 +03:00
ebb7edcc8b frontend: .......help........ 2025-10-13 16:59:16 +03:00
831f3a5f98 frontend: image file picker 2025-10-10 00:02:18 +03:00
ac97e5b152 frontend: implement marks input 2025-10-08 03:18:43 +03:00
faf221c9ba frontend: move style properties to components 2025-10-07 14:48:51 +03:00
c95aed01b5 backend: prepare to adding posts to database 2025-10-05 22:07:45 +03:00
59d092c9d0 backend: iplement marks search 2025-10-05 20:49:59 +03:00
23a00d2c06 backend: complete file uploading 2025-10-05 20:18:05 +03:00
da077519fe backend: return error 500 instead of zero for images 2025-10-05 20:04:32 +03:00
4921d4bd6f backend: convert input images to webp 2025-10-02 23:38:48 +03:00
d786d7f7af backend: database and file upload test 2025-10-02 23:38:36 +03:00
b62f6d87c7 backend: initial 2025-09-28 15:59:46 +03:00
e5932844e2 scripts fixes 2025-09-28 15:01:40 +03:00
4cddc90172 clickable calendar 2025-09-28 15:01:26 +03:00
70d53ff8a0 disallow robots 2025-09-28 15:00:49 +03:00
55ac4b7502 some kind of reactivity 2025-09-28 00:26:04 +03:00
0dee6d64ed Initial port to svelte 2025-09-14 18:26:46 +03:00
410cba70e5 add sveltekit template 2025-09-14 15:49:29 +03:00
50 changed files with 3058 additions and 935 deletions

7
.gitignore vendored
View file

@ -1,2 +1,7 @@
.venv/
venv/
__pycache__/
.idea/
.DS_Store
.DS_Store
*.db
store/

4
README.md Normal file
View file

@ -0,0 +1,4 @@
# Ситуация
На свелте мне нравится что получается что-то, но пока не нравится что получается.
Как будто бы надо будет сделать больбшой рефакторинг, но пока пускай будет хоть что-то.

70
backend/api.py Normal file
View file

@ -0,0 +1,70 @@
from typing import Annotated
from fastapi import FastAPI, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from vntypes import *
from utils import *
from db import VNDB
app = FastAPI()
database = VNDB()
origins = [
"http://localhost",
"http://localhost:5173",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/queue")
def get_post_queue() -> list[FullNovel]:
raise HTTPException(status_code=501, detail="Method is not implemented yet!")
@app.get("/api/posts/{post_id}")
def get_post(post_id: int):
raise HTTPException(status_code=501, detail="Method is not implemented yet!")
@app.post("/api/posts/{post_id}")
def new_post(novel: FullNovel):
print(novel)
return "yay!"
@app.patch("/api/posts/{post_id}")
def edit_post(novel: FullNovel):
print(novel)
return "yay!"
@app.post("/api/mark")
def new_mark(mark: Mark):
database.insert_mark(mark.type, mark.value)
return "yay!"
@app.post("/api/marks")
def search_marks(query: Mark):
return database.search_mark(query)
@app.post("/api/thumb")
async def upload_thumb(thumb: Annotated[bytes, File()], filename: str):
return {"file_size": save_image(thumb, "thumbs", filename)}
@app.post("/api/screenshot")
async def upload_screenshot(scrshot: Annotated[bytes, File()], filename: str):
return {"file_size": save_image(scrshot, "screens", filename)}
@app.post("/api/file")
async def upload_file(file: Annotated[bytes, File()], filename: str):
return {"file_size": save_file(file, "files", filename)}

111
backend/db.py Normal file
View file

@ -0,0 +1,111 @@
from datetime import datetime
import sqlite3
import json
from utils import asset
from vntypes import *
class VNDB:
def __init__(self, db_name=asset('vn_database.db')):
self.__db_name = db_name
with sqlite3.connect(self.__db_name) as connection:
cursor = connection.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS marks (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
value TEXT NOT NULL,
UNIQUE(id, value)
);
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS authors (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL,
thumbnail TEXT NOT NULL,
UNIQUE(id, title, description, thumbnail)
);
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS novels (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
author INTEGER NOT NULL,
UNIQUE(id, title, author)
);
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS posts_log (
novel INTEGER NOT NULL,
channel INTEGER NOT NULL,
marks JSON NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
author INTEGER NOT NULL,
files_on_disk JSON NOT NULL,
post_info JSON,
post_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
''')
connection.commit()
def __execute(self, sql: str, params: tuple = ()):
with sqlite3.connect(self.__db_name) as connection:
connection.cursor().execute(sql, params)
connection.commit()
def __fetch(self, sql: str, params: tuple = ()):
with sqlite3.connect(self.__db_name) as connection:
cursor = connection.cursor()
cursor.execute(sql, params)
return cursor.fetchall()
def search_author(self, title: str):
return self.__fetch("SELECT * FROM authors "
"WHERE title LIKE ?", (title,))
def search_mark(self, mark: Mark):
return self.__fetch("SELECT * FROM marks "
"WHERE value LIKE ? "
"AND type = ?", ('%'+mark.value+'%',mark.type))
def search_novel(self, title: str):
return self.__fetch("SELECT * FROM novels "
"WHERE title LIKE ? ", (title,))
def insert_author(self, title: str, desc: str, thumb: str):
self.__execute("INSERT INTO authors (title, description, thumbnail) "
"VALUES (?, ?, ?)", (title, desc, thumb))
def insert_mark(self, type: str, value: str):
self.__execute("INSERT INTO marks (type, value) "
"VALUES (?, ?)", (type, value))
def insert_novel(self, novel: Novel):
self.__execute("INSERT INTO novels (title, author) "
f"SELECT {novel.title}, {novel.author_id} "
f"WHERE NOT EXISTS(SELECT 1 FROM novels WHERE title = {novel.title} AND author = {novel.author_id});")
# FIXME: SQL Types
def insert_post(self, full_novel: FullNovel):
self.insert_novel(full_novel.data)
self.__execute("INSERT INTO posts_log (novel, channel, marks, title, description, author, files_on_disk, post_at, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(self.search_novel(full_novel.data.title)[0],
full_novel.data.tg_channel,
json.dumps(full_novel.data.marks),
full_novel.data.title,
full_novel.data.description,
full_novel.data.author_id,
json.dumps(full_novel.files),
full_novel.data.post_at,
datetime.now()))

34
backend/utils.py Normal file
View file

@ -0,0 +1,34 @@
from PIL import Image, UnidentifiedImageError
from fastapi import HTTPException
from pathlib import Path
import io
def asset(path: str) -> Path:
return Path("store", path)
def image_normalize(img: bytes) -> bytes:
try:
byte_arr = io.BytesIO()
img = Image.open(io.BytesIO(img))
img.save(byte_arr, format='WEBP')
return byte_arr.getvalue()
except UnidentifiedImageError:
raise HTTPException(status_code=500, detail="Image file cannot be readed!")
def save_image(img: bytes, dir: str, name: str) -> int:
path = asset(dir)
path.mkdir(exist_ok=True)
img = image_normalize(img)
with open(Path(path, name+'.jpg'), "wb") as file:
file.write(img)
return len(img)
def save_file(file_b: bytes, dir: str, name: str) -> int:
path = asset(dir)
path.mkdir(exist_ok=True)
with open(Path(path, name+'.jpg'), "wb") as file:
file.write(file_b)
return len(file_b)

33
backend/vntypes.py Normal file
View file

@ -0,0 +1,33 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Literal
class Mark(BaseModel):
type: Literal["tag", "badge", "genre"]
value: str
class NovelFile(BaseModel):
filename: str
platform: Literal["android", "ios", "windows", "linux", "macos"]
class Novel(BaseModel):
title: str
description: str
author_id: int
vndb: int | None = None
hours_to_read: int
marks: list[Mark]
tg_channel: int # maybe not here
tg_post: str | None = None #url::Url
post_at: datetime | None = None
class FullNovel(BaseModel):
data: Novel
#upload_queue: list[str]
files: list[NovelFile]
screenshots: list[str]

23
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
frontend/.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

38
frontend/README.md Normal file
View file

@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

19
frontend/jsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

1554
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
frontend/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "vnshed",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^7.0.4"
}
}

13
frontend/src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

11
frontend/src/app.html Normal file
View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Before After
Before After

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,79 @@
<script lang="ts">
let { handler = () => {}, type = "button", files = $bindable() } = $props();
let file_list: FileList = $state([]);
$effect(() => {
// FileList -> Array[File]
let f_list: Array<File> = [];
for (let file of file_list)
f_list.push(file);
files = f_list;
});
</script>
{#if type == "button"}
<button class="plus round" style="height: 32px;" onclick={handler}>+</button>
{:else if type == "image"}
<label for="img-upload" class="plus round" style="height: 48px; display: inline-block;">+</label>
<input id="img-upload" accept="image/png, image/jpeg" style="display: none" bind:files={file_list} type="file"/>
{:else}
<label for="file-upload" class="file-plus rounded">Загрузить файл</label>
<input id="file-upload" style="display: none" bind:files={file_list} multiple type="file"/>
{/if}
<style>
/* Кнопка "плюс" */
.plus {
font-family: Arial, Helvetica, sans-serif;
aspect-ratio: 1 / 1;
font-size: x-large;
cursor: pointer;
text-align: center;
align-content: center;
}
.file-plus {
font-size: larger;
cursor: pointer;
text-align: center;
align-content: center;
width: 100%;
height: 3rem;
border: 0.15rem dashed;
display: block;
}
@media (prefers-color-scheme: light) {
.plus {
background-color: #5C8DC0;
color: white;
}
.file-plus {
background: none;
border-color: #5C8DC0;
color: #5C8DC0;
}
.plus:hover {
background-color: #567aa1;
}
}
@media (prefers-color-scheme: dark) {
.plus {
background-color: #2791FF;
color: white;
}
.file-plus {
background: none;
border-color: #2791FF;
color: #2791FF;
}
.plus:hover {
background-color: #2c7dd4;
}
}
</style>

View file

@ -0,0 +1,188 @@
<script lang="ts">
let current: Number = 9;
function set_day(day: Number) {
current = day;
return null;
}
</script>
<div class="calendar input rounded">
<div class="month">
<ul>
<li class="prev">&#10094;</li>
<li class="next">&#10095;</li>
<li>Август 2025</li>
</ul>
</div>
<ul class="weekdays">
{#each ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"] as weekday}
<li>{weekday}</li>
{/each}
</ul>
<ul class="days">
{#each [...Array(31).keys()] as day}
<li>
{#if current == day+1}
<button class="active">{day+1}</button>
{:else}
<button class="nonactive" onclick={set_day(day+1)}>{day+1}</button>
{/if}
</li>
{/each}
</ul>
</div>
<style>
ul {list-style-type: none;}
.calendar {
padding: 0;
}
.calendar * {
margin: 0;
}
.month {
width: 100%;
text-align: center;
align-content: center;
}
.month ul {
margin: 0;
padding: 0.5rem;
}
.month ul li {
display: inline;
text-transform: uppercase;
letter-spacing: 3px;
}
.month .prev {
float: inline-start;
cursor: pointer;
}
.month .next {
float: inline-end;
cursor: pointer;
}
.weekdays {
padding: 10px 0 0 0;
}
.weekdays li {
display: inline-block;
width: 13.6%;
text-align: center;
}
.days {
padding: 10px 0;
margin: 0;
}
.days li {
list-style-type: none;
display: inline-block;
width: 13.6%;
text-align: center;
margin-bottom: 5px;
font-size: smaller;
cursor: pointer;
}
.days li .active {
height: 2em;
padding: 5px;
border-radius: 30%;
aspect-ratio: 1/1;
}
.days li .nonactive {
height: 2em;
padding: 5px;
background: none;
aspect-ratio: 1/1;
}
@media screen and (max-width: 720px) {
.weekdays li, .days li {width: 13.1%;}
}
@media screen and (max-width: 420px) {
.weekdays li, .days li {width: 12.5%;}
.days li .active {padding: 2px;}
.days li .nonactive {padding: 2px;}
}
@media screen and (max-width: 290px) {
.weekdays li, .days li {width: 12.2%;}
}
@media (prefers-color-scheme: light) {
.month {
background: #5C8DC0;
}
.month ul li {
color: white;
}
.weekdays {
background-color: #F4F4F4;
}
.weekdays li {
color: black;
}
.days {
background: #F4F4F4;
}
.days li {
color: black;
}
.days li .active {
color: white !important;
background: #5C8DC0;
}
}
@media (prefers-color-scheme: dark) {
.month {
background: #2791FF;
}
.month ul li {
color: white;
}
.weekdays {
background-color: #192431;
}
.weekdays li {
color: white;
}
.days {
background: #192431;
}
.days li {
color: white;
}
.days li .active {
color: white !important;
background: #2791FF;
}
}
</style>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import File from '$lib/components/file.svelte';
import AddButton from '$lib/components/add_button.svelte';
let selected = $state([]);
let files: Array<File> = $state([]);
$effect(() => {
if (selected.length > 0) {
let added: Array<File> = [];
for (let file of selected)
added.push(file);
files = files.concat(added);
selected = [];
}
});
</script>
<div class="input rounded">
{#each files as file}
<File data={file}/>
{/each}
<AddButton type="file" bind:files={selected}/>
</div>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import Image from "../image.svelte";
let { value = $bindable([]) } = $props();
</script>
<div class="img-conatiner rounded">
<Image />
</div>
<style>
/* Горизонтальный список */
.img-conatiner {
display: flex;
overflow-x: scroll;
align-content: center;
}
.img-conatiner :global(*) {
margin: 0;
margin-right: 0.5rem;
}
</style>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import Mark from "../mark.svelte";
import AddButton from "../add_button.svelte";
let { value = $bindable([]), label = "" } = $props();
let items: string[] = $state([]);
</script>
<div class="marks-container input rounded">
<p>{label}</p>
{#each items as item}
<Mark text={item}/>
{/each}
<AddButton handler={() => {
items.push("");
value = items;
}} />
</div>
<style>
/* Горизонтальный список */
.marks-container {
display: flex;
overflow-x: scroll;
align-content: center;
padding: 0.5rem;
}
.marks-container :global(*) {
margin: 0;
margin-right: 0.5rem;
}
.marks-container p {
align-content: center;
}
</style>

View file

@ -0,0 +1,8 @@
<script lang="ts">
import QueueItem from '$lib/components/queue_item.svelte';
</script>
<div class="input rounded">
<QueueItem date="28.11.24" name="Fate/Stay Night (Судьба/Ночь схватки)"/>
<QueueItem date="30.11.24" name="Fate/Hollow Ataraxia (Судьба/Святая атараксия)"/>
</div>

View file

@ -0,0 +1,69 @@
<!-- Кнопка "Создать" -->
<script lang="ts">
let { data = $bindable() } = $props();
function send_json() {
fetch('http://127.0.0.1:8000/new', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': "<origin>"
},
body: JSON.stringify(data)
});
}
</script>
<div class="create">
<button class="rounded" id="create" onclick={send_json}>Создать</button>
</div>
<style>
/* Поле с кнопкой "Создать" */
.create {
width: 100%;
position: fixed;
bottom: 0;
text-align: center;
display: flex;
flex-direction: column;
}
.create button {
margin: 0.5rem;
padding: 1rem;
cursor: pointer;
}
@media (prefers-color-scheme: light) {
.create {
background-color: #D9D9D9;
}
.create button {
background-color: #3CAA36;
color: white;
}
.create button:hover {
background-color: #3b8d37;
color: white;
}
}
@media (prefers-color-scheme: dark) {
.create {
background-color: #192431;
}
.create button {
background-color: #3CAA36;
color: white;
}
.create button:hover {
background-color: #3b8d37;
color: white;
}
}
</style>

View file

@ -0,0 +1,67 @@
<script lang="ts">
let { data } = $props();
const names = {
0: ["B", 0],
1: ["KB", 0],
2: ["MB", 1],
3: ["GB", 2],
4: ["TB", 3]
};
function size_str(size: number) {
let iter = 0;
while (size >= 1000) {
iter++;
size = size / 1000;
}
return size.toFixed(names[iter][1]).toString() + names[iter][0];
}
</script>
<div class="file rounded">
<p class="filename">{data.name}</p>
<p class="progress">{size_str(data.size)}</p>
</div>
<style>
.file {
width: 100%;
margin: 0;
height: 3rem;
display: grid;
margin-bottom: 0.5rem;
}
.file p {
margin: 0;
}
@media (prefers-color-scheme: light) {
.file {
background: #5C8DC0;
}
.filename {
color: white;
}
.progress {
color: #D4D4D4;
}
}
@media (prefers-color-scheme: dark) {
.file {
background: #2791FF;
}
.filename {
color: white;
}
.progress {
color: #D4D4D4;
}
}
</style>

View file

@ -0,0 +1,52 @@
<script>
import AddButton from "./add_button.svelte";
let { direction = "horisontal" } = $props();
let images = $state();
let image = $state();
$effect(() => {
if (images.length > 0) {
const reader = new FileReader();
reader.addEventListener("load", function () {
image.setAttribute("src", reader.result);
});
reader.readAsDataURL(images[0]);
}
});
</script>
<div class="{direction} input img-div rounded">
<AddButton type="image" bind:files={images}/>
<img bind:this={image} src="" alt="">
</div>
<style>
/* Вертикальное изображение */
.vertical {
aspect-ratio: 11 / 16;
padding: 0;
}
/* Горизонтальное изображение */
.horisontal {
aspect-ratio: 16 / 11;
padding: 0;
}
:global(.img-div) {
margin-left: auto;
margin-right: auto;
align-content: center;
text-align: center;
width: 11rem;
}
:global(.img-div) img {
object-fit: cover;
width: 100%;
}
:global(.img-div) :global(.plus) {
z-index: 2;
}
</style>

View file

@ -0,0 +1,48 @@
<script>
let { style, image, name } = $props();
</script>
<div class="imgbutton {style}">
<img src={image} alt={name}>
</div>
<style>
/* Кнопка с изображением */
.imgbutton {
height: 100%;
display: flex;
flex-direction: column;
margin: 0;
align-content: center;
text-align: center;
font-size: small;
padding: 0.5rem;
}
.imgbutton img {
margin-left: auto;
margin-right: auto;
margin: 0;
width: 2rem;
}
.imgbutton {
color: white;
}
.remove {
background-color: #FC1F1C;
}
.remove:hover {
background-color: #d32522;
}
.edit {
background-color: #1BC304;
}
.edit:hover {
background-color: #1c9d0b;
}
</style>

View file

@ -0,0 +1,94 @@
<script lang="ts">
let { text } = $props();
let compl_data = $state([]);
function autocomplete() {
if (text !== "") {
fetch('http://127.0.0.1:8000/api/marks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': "<origin>"
},
body: JSON.stringify({type: "tag", value: text})
}).then((response) => response.json())
.then((json) => compl_data = json);
} else {
compl_data = [];
}
}
</script>
<div>
<input type="text"
class="dyn-input rounded"
style="width: {text.length}ch;margin:0;"
bind:value={text}
oninput={autocomplete}>
{#if compl_data}
<div class="popup rounded" tabindex="0" role="button" onmousedown={(e) => {e.preventDefault();}}>
{#each compl_data as item}
<button onclick={text = this.textContent}>{item[2]}</button>
{/each}
</div>
{/if}
</div>
<style>
/* Элемент вертикального списка (тег, бадж, жанр) */
.dyn-input {
padding: 0.25rem;
background: none;
border: none;
text-align: center;
}
.popup {
text-align: center;
display: none;
position: absolute;
margin-top: 0.5rem;
box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
}
.popup * {
background: none;
margin: 0.5rem;
}
.dyn-input:focus + .popup {
display: grid;
}
@media (prefers-color-scheme: light) {
.popup {
background-color: #F4F4F4;
}
.dyn-input {
background-color: #5C8DC0;
color: white;
}
.dyn-input:hover {
background-color: #567aa1;
}
}
@media (prefers-color-scheme: dark) {
.popup {
background-color: #192431;
}
.dyn-input {
background-color: #2791FF;
color: white;
}
.dyn-input:hover {
background-color: #2c7dd4;
}
}
</style>

View file

@ -0,0 +1,56 @@
<script>
import { makeid } from "$lib/functions.js"
import edit_btn from '$lib/assets/edit.svg';
import remove_btn from '$lib/assets/delete.svg';
import ImgButton from "./img_button.svelte";
let id = makeid(8);
let { date, name } = $props();
</script>
<div class="queue-item rounded">
<p>{date}</p>
<p class="ellipsis">{name}</p>
<ImgButton style="edit" name="Изменить" image={edit_btn}/>
<ImgButton style="remove" name="Удалить" image={remove_btn}/>
</div>
<style>
/* Элемент в очереди */
.queue-item {
margin: 0;
padding: 0;
display: flex;
align-content: center;
}
.queue-item p {
align-content: center;
margin: 0.5rem;
}
:global(.queue-item + .queue-item) {
margin-top: 0.5rem;
}
@media (prefers-color-scheme: light) {
.queue-item {
background-color: #5C8DC0;
color: white;
}
.queue-item p {
color: white;
}
}
@media (prefers-color-scheme: dark) {
.queue-item {
background-color: #2791FF;
color: white;
}
.queue-item p {
color: white;
}
}
</style>

View file

@ -0,0 +1,92 @@
@font-face {
font-family: 'Inter';
src: url('/font/Inter-Light.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Шрифт для всей страницы */
* {
font-family: Inter;
font-size: large;
}
body {
margin: 0;
}
button {
border: 0;
}
/* Все элементы для ввода данных (input, textarea, собственные) */
.input {
border: 0;
resize: none;
padding: 0.5rem;
}
/* Круглый элемент */
.round {
border-radius: 100%;
overflow: hidden;
}
/* Закруглённый элемент */
.rounded {
border-radius: 0.75rem;
overflow: hidden;
}
/* Поле с обложкой */
.cover {
padding: 2rem;
text-align: center;
align-content: center;
}
/* Поле с заполняемыми полями */
.fields {
height: fit-content;
display: flex;
flex-direction: column;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding: 1rem;
padding-bottom: 5rem;
}
/* Отступ между полями */
.fields .input {
margin: 0;
margin-bottom: 1rem;
}
.fields p {
margin: 0;
margin-bottom: 1rem;
margin-left: 0.25rem;
}
.horisontal-bar {
display: flex;
align-content: center;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.horisontal-bar p {
margin: 0;
margin-right: 0.5rem;
align-content: center;
margin-bottom: 1rem;
}
/* Текст с троеточием в конце */
.ellipsis {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

View file

@ -0,0 +1,53 @@
@media (prefers-color-scheme: light) {
body {
background-color: #EFEFF3;
}
p {
color: black;
}
.input {
background-color: #F4F4F4;
color: black;
}
.input::placeholder {
color: black;
}
.cover .img-div {
background-color: white;
}
.fields {
background-color: white;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #0F1011;
}
p {
color: white;
}
.input {
background-color: #192431;
color: white;
}
.input::placeholder {
color: white;
}
.cover .img-div {
background-color: #131A22;
}
.fields {
background-color: #131A22;
}
}

View file

@ -0,0 +1,46 @@
export function makeid(length) {
var result = '';
var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var charactersLength = characters.length;
for ( var i = 0; i < length; i++ ) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
// const imageTypes = {
// types: [
// {
// description: "Images",
// accept: {
// "image/*": [".png", ".gif", ".jpeg", ".jpg"],
// },
// },
// ],
// excludeAcceptAllOption: true,
// multiple: false,
// };
// let fileHandle;
// export async function loadImage() {
// [fileHandle] = await window.showOpenFilePicker(imageTypes);
// return fileHandle;
// }
// export function addImage(initiator, target) {
// loadImage().then(async (result) => {
// let img = document.createElement("img");
// img.src = URL.createObjectURL(await result.getFile());
// document.getElementById(target).appendChild(img);
// initiator = document.getElementById(initiator);
// if (initiator) initiator.remove();
// //document.getElementById(target).addEventListener("click", addImage("", target));
// });
// }
// export function json_filter(key,value)
// {
// if (key=="html") return undefined;
// //else if (key=="id") return undefined;
// else return value;
// }

View file

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

View file

@ -0,0 +1,80 @@
<script>
import favicon from '$lib/assets/favicon.svg';
import Calendar from '$lib/components/calendar.svelte';
import CreateButton from '$lib/components/create_button.svelte';
import Image from '$lib/components/image.svelte';
import FileContainer from '$lib/components/containers/files.svelte';
import QueueContainer from '$lib/components/containers/queue.svelte';
import MarksContainer from '$lib/components/containers/marks.svelte';
import ImageContainer from '$lib/components/containers/images.svelte';
let { children } = $props();
let page_data = $state({
title: "",
description: "",
hours_to_read: 0,
genres: [],
tags: [],
badges: []
})
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<!-- Обложка -->
<div class="cover">
<Image direction="vertical"/>
<p>Обложка</p>
</div>
<div class="fields rounded" id="fields">
<!-- Название -->
<input type="text" class="input rounded" placeholder="Название" bind:value={page_data["title"]}>
<!-- Описание -->
<textarea class="input rounded" placeholder="Описание" rows="4" bind:value={page_data["description"]}></textarea>
<!-- Время на чтение -->
<div class="horisontal-bar">
<p>Время на чтение</p>
<select class="input rounded" id="hours-count" bind:value={page_data["hours_to_read"]}>
<option value="2">менее 2-х часов</option>
<option value="10">2-10 часов</option>
<option value="30">10-30 часов</option>
<option value="50">50+ часов</option>
</select>
</div>
<MarksContainer label="Жанры" bind:value={page_data["genres"]}/>
<MarksContainer label="Теги" bind:value={page_data["tags"]}/>
<MarksContainer label="Баджи" bind:value={page_data["badges"]}/>
<p>Скриншоты</p>
<ImageContainer />
<!-- Календарь с датой -->
<p>Дата публикации</p>
<Calendar />
<!-- Файлы -->
<p>Файлы</p>
<FileContainer />
<!-- Очередь публикации -->
<p>Очередь публикации</p>
<QueueContainer />
</div>
<CreateButton bind:data={page_data}/>
<style>
@import '$lib/css/global.css';
@import '$lib/css/themes.css';
</style>
{@render children?.()}

View file

@ -0,0 +1,2 @@
<!-- <h1>Welcome to SvelteKit</h1>
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p> -->

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

13
frontend/svelte.config.js Normal file
View file

@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

6
frontend/vite.config.js Normal file
View file

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});

66
hmm.txt Normal file
View file

@ -0,0 +1,66 @@
-- Эта таблица нужна, чтобы когда человек набирает в поле ввода тег или жанр или бадж,
-- ему подсказывало уже существующие теги/жанры/баджи
CREATE TABLE IF NOT EXISTS mark (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
-- "genre" or "tag" or "badge"
tag TEXT NOT NULL,
-- value, "romance", "Хохлы", as an example
value TEXT NOT NULL
);
-- Таблица с авторами, для того же - чтобы подсказывало существующего автора.
CREATE TABLE IF NOT EXISTS authors (
-- ID автора, для кросс-референсов.
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
-- Имя автора.
title TEXT NOT NULL,
-- Описание автора.
description TEXT NOT NULL,
-- Путь к файлу обложки автора.
thumbnail TEXT NOT NULL
);
-- Сами новеллы. Нужно, поскольку несколько постов могут отсылаться к одной игре.
CREATE TABLE IF NOT EXISTS novels (
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
-- Название. Используется исключительно для поиска.
title TEXT NOT NULL,
author INTEGER NOT NULL
);
-- Таблица с **постами**.
CREATE TABLE IF NOT EXISTS posts_log (
-- Кросс-референс на id в novels.
novel INTEGER NOT NULL,
-- ID канала, в который пост будет запощен.
channel INTEGER NOT NULL,
-- массив idшников на записи в marks.
marks JSON NOT NULL,
-- Название внки.
title TEXT NOT NULL,
-- Описание ВНки.
description TEXT NOT NULL,
-- id автора на момент планирования поста.
author INTEGER NOT NULL,
-- Мапа, ключ - путь к файлу в фс, значение -
-- `{"post": <POST_LINK>, "description": "описание файла"}`
-- <POST_LINK> - ссылка на пост в соответствующем канале с файлами, может
-- быть null. Не должно быть null, если post_info != NULL (то есть если уже запостили).
files_on_disk JSON NOT NULL,
-- не NULL, если пост успешно запостили.
-- {"link": <ссылка на пост>}
post_info JSON,
-- Когда ВН должна быть запощена, second-precise unix timestamp.
post_at INTEGER NOT NULL,
-- Когда запись в бд была создана, second-precise unix timestamp.
created_at INTEGER NOT NULL
);

9
test_data.json Normal file
View file

@ -0,0 +1,9 @@
{
"title": "Higurashi no Naku Koro ni. Console-exclusive Arcs (Когда плачут цикады. Эксклюзивные главы)",
"description": "Эксклюзивные главы для консольных версий \"Higurashi no Naku Koro ni\". Главы рекомендуется читать после ознакомления с оригинальными главами.\n\nUPD: На данный момент переведены: Taraimawashi, Tsukiotoshi, Hajisarashi, Miotsukushi и Материалы полиции\n\nОписание глав:\n\nTaraimawashi - Альтернативная первая глава, на первый взгляд — это дополнение к арке \"Вопросов\", пересказ Onikakushi-hen. Тем не менее, эта глава на самом деле содержит события Watanagashi-hen.\nУзнав секреты Хинамидзавы, Кейти решает игнорировать всё и наслаждаться мирной школьной жизнью.\nTsukiotoshi - Оригинальная консольная глава, которая является развилкой для третьей главы \"Tatarigoroshi\".\nДядя Сатоко приезжает в Хинамидзаву, и Кейти решает найти союзников, чтобы помочь Сатоко. Возможно, \"худший\" из миров, где на кубиках выпали одни \"единицы.\"\nHajisarashi - одним жарким днём Рика и её друзья идут в бассейн.\nMiotsukushi - альтернативная концовка всей оригинальной серии.\nМатериалы полиции - краткие истории по длине равных TIPS, которые немного раскрывают Оиши и Акасаку..[/i]",
"hours_to_read": 50,
"genres": [ "Драма", "Хоррор", "Детектив", "Мистика", "Повседневность" ],
"tags": [ "Иностранный разработчик", "Несколько главных героев" ],
"badges": [ "Лучшее" ],
"post_at": "2024-04-13T08:30:00Z"
}

View file

@ -1,21 +0,0 @@
from datetime import datetime
class Novel:
title: str
description: str
vndb: int
hours_to_read: int
tags: list[str]
genres: list[str]
tg_post: str #url::Url
post_at: datetime
class FullNovel:
data: Novel
upload_queue: list[str]
files: list[str]
screenshots: list[str]

View file

@ -1,75 +0,0 @@
ul {list-style-type: none;}
.calendar * {
margin: 0;
}
.month {
width: 100%;
text-align: center;
align-content: center;
}
.month ul {
margin: 0;
padding: 0.5rem;
}
.month ul li {
display: inline;
text-transform: uppercase;
letter-spacing: 3px;
}
.month .prev {
float: inline-start;
cursor: pointer;
}
.month .next {
float: inline-end;
cursor: pointer;
}
.weekdays {
padding: 10px 0 0 0;
}
.weekdays li {
display: inline-block;
width: 13.6%;
text-align: center;
}
.days {
padding: 10px 0;
margin: 0;
}
.days li {
list-style-type: none;
display: inline-block;
width: 13.6%;
text-align: center;
margin-bottom: 5px;
font-size: smaller;
cursor: pointer;
}
.days li .active {
padding: 5px;
border-radius: 30%;
}
@media screen and (max-width: 720px) {
.weekdays li, .days li {width: 13.1%;}
}
@media screen and (max-width: 420px) {
.weekdays li, .days li {width: 12.5%;}
.days li .active {padding: 2px;}
}
@media screen and (max-width: 290px) {
.weekdays li, .days li {width: 12.2%;}
}

View file

@ -1,195 +0,0 @@
@font-face {
font-family: 'Inter';
src: url('../font/Inter-Light.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Lobster';
src: url('../font/Lobster-Regular.ttf') format('ttf');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Шрифт для всей страницы */
* {
font-family: Inter;
font-size: large;
}
body {
margin: 0;
}
button {
border: 0;
}
/* Все элементы для ввода данных (input, textarea, собственные) */
.input {
border: 0;
resize: none;
padding: 0.5rem;
}
/* Круглый элемент */
.round {
border-radius: 100%;
overflow: hidden;
}
/* Закруглённый элемент */
.rounded {
border-radius: 0.75rem;
overflow: hidden;
}
/* Вертикальное изображение */
.vertical {
aspect-ratio: 11 / 16;
}
/* Горизонтальное изображение */
.horisontal {
aspect-ratio: 16 / 11;
}
.image {
margin-left: auto;
margin-right: auto;
align-content: center;
text-align: center;
width: 11rem;
}
/* Кнопка "плюс" */
.plus {
font-family: Arial, Helvetica, sans-serif;
aspect-ratio: 1 / 1;
height: 32px;
font-size: x-large;
cursor: pointer;
}
/* Кнопка "плюс" на изображении должна быть больше */
.image .plus {
height: 48px;
}
/* Поле с обложкой */
.cover {
padding: 2rem;
text-align: center;
align-content: center;
}
/* Поле с кнопкой "Создать" */
.create {
width: 100%;
position: fixed;
bottom: 0;
text-align: center;
display: flex;
flex-direction: column;
}
.create button {
margin: 0.5rem;
padding: 1rem;
cursor: pointer;
}
/* Поле с заполняемыми полями */
.fields {
width: 100%;
height: fit-content;
display: flex;
flex-direction: column;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
padding-bottom: 5rem;
}
/* Отступ между полями */
.fields * {
margin: 1rem;
margin-bottom: 0;
}
/* Вертикальный список */
.horisontal-bar {
display: flex;
overflow-x: scroll;
align-content: center;
padding: 0.5rem;
}
.horisontal-bar * {
margin: 0;
margin-right: 0.5rem;
align-content: center;
}
/* Элемент вертикального списка (тег, бадж, жанр) */
.horisontal-item {
padding: 0.25rem;
cursor: pointer;
}
/* Элемент в очереди */
.queue-item {
margin: 0;
padding: 0;
}
.queue-item + .queue-item {
margin-top: 0.5rem;
}
.queue-item p {
margin: 0.5rem;
}
/* Текст с троеточием в конце */
.ellipsis {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Кнопка с изображением */
.imgbutton {
height: 100%;
display: flex;
flex-direction: column;
margin: 0;
align-content: center;
text-align: center;
font-size: small;
padding: 0.5rem;
}
.imgbutton * {
margin: 0;
}
.imgbutton img {
margin-left: auto;
margin-right: auto;
width: 2rem;
}
/* Кнопка "Удалить" */
.remove {
cursor: pointer;
}
/* Кнопка "Изменить" */
.edit {
cursor: pointer;
margin-left: auto;
}

View file

@ -1,233 +0,0 @@
@media (prefers-color-scheme: light) {
body {
background-color: #EFEFF3;
}
p {
color: black;
}
.input {
background-color: #F4F4F4;
color: black;
}
.input::placeholder {
color: black;
}
.plus {
background-color: #5C8DC0;
color: white;
}
.plus:hover {
background-color: #567aa1;
}
.horisontal-item {
background-color: #5C8DC0;
color: white;
}
.horisontal-item:hover {
background-color: #567aa1;
}
.cover .image {
background-color: white;
}
.create {
background-color: #D9D9D9;
}
.create button {
background-color: #3CAA36;
color: white;
}
.create button:hover {
background-color: #3b8d37;
color: white;
}
.fields {
background-color: white;
}
.queue-item {
background-color: #5C8DC0;
color: white;
}
.queue-item p {
color: white;
}
.imgbutton {
color: white;
}
.month {
background: #5C8DC0;
}
.month ul li {
color: white;
}
.weekdays {
background-color: #F4F4F4;
}
.weekdays li {
color: black;
}
.days {
background: #F4F4F4;
}
.days li {
color: black;
}
.days li .active {
color: white !important;
background: #5C8DC0;
}
.remove {
background-color: #FC1F1C;
}
.remove:hover {
background-color: #d32522;
}
.edit {
background-color: #1BC304;
}
.edit:hover {
background-color: #1c9d0b;
}
}
@media (prefers-color-scheme: dark) {
body {
background-color: #0F1011;
}
p {
color: white;
}
.input {
background-color: #192431;
color: white;
}
.input::placeholder {
color: white;
}
.plus {
background-color: #2791FF;
color: white;
}
.plus:hover {
background-color: #2c7dd4;
}
.horisontal-item {
background-color: #2791FF;
color: white;
}
.horisontal-item:hover {
background-color: #2c7dd4;
}
.cover .image {
background-color: #131A22;
}
.create {
background-color: #192431;
}
.create button {
background-color: #3CAA36;
color: white;
}
.create button:hover {
background-color: #3b8d37;
color: white;
}
.fields {
background-color: #131A22;
}
.queue-item {
background-color: #2791FF;
color: white;
}
.queue-item p {
color: white;
}
.imgbutton {
color: white;
}
.month {
background: #2791FF;
}
.month ul li {
color: white;
}
.weekdays {
background-color: #192431;
}
.weekdays li {
color: white;
}
.days {
background: #192431;
}
.days li {
color: white;
}
.days li .active {
color: white !important;
background: #2791FF;
}
.remove {
background-color: #FC1F1C;
}
.remove:hover {
background-color: #d32522;
}
.edit {
background-color: #1BC304;
}
.edit:hover {
background-color: #1c9d0b;
}
}

View file

@ -1,95 +0,0 @@
<html>
<head>
<title>Отложка</title>
<link rel="stylesheet" type="text/css" href="css/global.css">
<link rel="stylesheet" type="text/css" href="css/calendar.css">
<link rel="stylesheet" type="text/css" href="css/themes.css">
<link rel="preload" href="font/Inter-Light.woff2" as="font" type="font/ttf" crossorigin>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8" />
</head>
<body>
<script type="text/javascript" src="js/functions.js"></script>
<script type="text/javascript" src="js/types.js"></script>
<!-- Обложка -->
<div class="cover">
<div class="vertical image rounded" id="cover-div">
<button class="plus round" id="cover-plus">+</button>
</div>
<p>Обложка</p>
</div>
<div class="fields rounded" id="fields">
<!-- Название -->
<input type="text" class="input rounded" placeholder="Название"></input>
<!-- Описание -->
<textarea class="input rounded" placeholder="Описание" rows="4"></textarea>
<!-- Время на чтение -->
<div class="horisontal-bar">
<p>Время на чтение</p>
<select class="input rounded" id="hours-count">
<option value="first">менее 2-х часов</option>
<option value="second">2-10 часов</option>
<option value="third">10-30 часов</option>
<option value="third">50+ часов</option>
</select>
</div>
<!-- Поле с жанрами -->
<script>
let genres = new HorisontalBar("Жанры", "genres");
document.getElementById("fields").appendChild(genres.html);
</script>
<!-- Поле с тегами -->
<script>
let tags = new HorisontalBar("Теги", "tags");
document.getElementById("fields").appendChild(tags.html);
</script>
<!-- Поле с баджами -->
<script>
let badges = new HorisontalBar("Баджи", "badges");
document.getElementById("fields").appendChild(badges.html);
</script>
<!-- Скриншоты -->
<p>Скриншоты</p>
<script>
let screenshots = new HorisontalImageBar("screenshots");
document.getElementById("fields").appendChild(screenshots.html);
</script>
<!-- Календарь с датой -->
<p>Дата публикации</p>
<script>
let calendar = new Calendar();
document.getElementById("fields").appendChild(calendar.html);
</script>
<!-- Очередь публикации -->
<p>Очередь публикации</p>
<div class="input rounded" id="queue">
<script>
let queue1 = new QueueItem("28.11.24", "Fate/Stay Night (Судьба/Ночь схватки)");
document.getElementById("queue").appendChild(queue1.html);
</script>
<script>
let queue2 = new QueueItem("30.11.24", "Fate/Hollow Ataraxia (Судьба/Святая атараксия)");
document.getElementById("queue").appendChild(queue2.html);
</script>
</div>
</div>
<!-- Кнопка "Создать" -->
<div class="create">
<button class="rounded" id="create">Создать</button>
</div>
<script type="text/javascript" src="js/index.js"></script>
<script type="text/javascript" src="js/calendar.js"></script>
</body>
</html>

View file

@ -1,8 +0,0 @@
document.getElementById("cal-prev").addEventListener("click", (event) => {
console.log("cal-prev");
})
document.getElementById("cal-next").addEventListener("click", (event) => {
console.log("cal-next");
})

View file

@ -1,46 +0,0 @@
function makeid(length) {
var result = '';
var characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var charactersLength = characters.length;
for ( var i = 0; i < length; i++ ) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
const imageTypes = {
types: [
{
description: "Images",
accept: {
"image/*": [".png", ".gif", ".jpeg", ".jpg"],
},
},
],
excludeAcceptAllOption: true,
multiple: false,
};
let fileHandle;
async function loadImage() {
[fileHandle] = await window.showOpenFilePicker(imageTypes);
return fileHandle;
}
function addImage(initiator, target) {
loadImage().then(async (result) => {
let img = document.createElement("img");
img.src = URL.createObjectURL(await result.getFile());
document.getElementById(target).appendChild(img);
initiator = document.getElementById(initiator);
if (initiator) initiator.remove();
//document.getElementById(target).addEventListener("click", addImage("", target));
});
}
function json_filter(key,value)
{
if (key=="html") return undefined;
//else if (key=="id") return undefined;
else return value;
}

View file

@ -1,12 +0,0 @@
document.getElementById("cover-plus").addEventListener("click", (event) => {
addImage("cover-plus", "cover-div");
})
// document.getElementById("screen-plus").addEventListener("click", (event) => {
// addImage("screen-plus", "scr-1");
// })
document.getElementById("create").addEventListener("click", (event) => {
console.log("create");
})

View file

@ -1,249 +0,0 @@
class AddButton {
id = "";
html = undefined;
constructor() {
this.html = document.createElement('button');
this.html.classList.add("plus");
this.html.classList.add("round");
this.html.appendChild(document.createTextNode("+"));
this.id = makeid(8);
this.html.id = this.id;
}
addHadler(func) {
this.html.addEventListener("click", func);
return this;
}
}
class HorisontalItem {
id = undefined;
html = undefined;
text = undefined;
constructor(text) {
this.text = text;
this.html = document.createElement('div');
this.html.classList.add("horisontal-item");
this.html.classList.add("rounded");
this.html.appendChild(document.createTextNode(text));
this.id = makeid(8);
this.html.id = this.id;
this.html.addEventListener("click", this.remove);
}
remove() {
document.getElementById(this.id).remove();
}
}
class HorisontalBar {
html = undefined;
plus_id = undefined;
elements = [];
constructor(text, id) {
this.html = document.createElement('div');
this.html.classList.add("horisontal-bar");
this.html.classList.add("input");
this.html.classList.add("rounded");
let label = document.createElement('p');
label.appendChild(document.createTextNode(text))
this.html.appendChild(label);
const plus = new AddButton().addHadler(
() => this.append(new HorisontalItem('test'))
);
this.html.appendChild(plus.html);
this.plus_id = plus.id;
}
append(elem) {
this.elements.push(elem);
this.html.insertBefore(elem.html, document.getElementById(this.plus_id));
return this;
}
json() {
return JSON.stringify(this.elements, json_filter);
}
}
class HorisontalImage {
id = undefined;
html = undefined;
constructor() {
this.html = document.createElement('div');
this.html.classList.add("horisontal");
this.html.classList.add("image");
this.html.classList.add("input");
this.html.classList.add("rounded");
this.id = makeid(8);
this.html.id = this.id;
let plus = new AddButton();
plus.addHadler(() => {
addImage(plus.id, this.id);
document.getElementById("screenshots").appendChild(new HorisontalImage().html);
});
this.html.appendChild(plus.html);
}
remove() {
document.getElementById(this.id).remove();
}
}
class HorisontalImageBar {
html = undefined;
first_id = undefined;
elements = [];
constructor(id) {
this.html = document.createElement('div');
this.html.classList.add("horisontal-bar");
const first = new HorisontalImage();
this.html.appendChild(first.html);
this.first_id = first.id;
}
append(elem) {
this.elements.push(elem);
this.html.insertBefore(elem.html, document.getElementById(this.first_id));
return this;
}
json() {
return JSON.stringify(this.elements, json_filter);
}
}
class ImgButton {
html = undefined;
id = undefined;
constructor(name, img) {
this.html = document.createElement('div');
this.html.classList.add("imgbutton");
this.id = makeid(8);
this.html.id = this.id;
let btn_img = document.createElement('img');
btn_img.src = img;
btn_img.innerHTML = name;
this.html.appendChild(btn_img);
}
addHadler(func) {
this.html.addEventListener("click", func);
return this;
}
setCssClass(name) {
this.html.classList.add(name);
}
}
class QueueItem {
html = undefined;
id = undefined;
constructor(date, name) {
this.html = document.createElement('div');
this.html.classList.add("queue-item");
this.html.classList.add("horisontal-bar");
this.html.classList.add("rounded");
this.id = makeid(8);
this.html.id = this.id;
var date_p = document.createElement('p');
date_p.innerHTML = date;
this.html.appendChild(date_p);
var text = document.createElement('p');
text.classList.add("ellipsis");
text.innerHTML = name;
this.html.appendChild(text);
let edit_btn = new ImgButton("Изменить", "./img/edit.svg");
edit_btn.setCssClass("edit");
this.html.appendChild(edit_btn.html);
let rem_btn = new ImgButton("Удалить", "./img/delete.svg");
rem_btn.setCssClass("remove");
this.html.appendChild(rem_btn.html);
}
}
class Calendar {
html = undefined;
id = undefined;
constructor() {
this.html = document.createElement('div');
this.html.classList.add("calendar");
this.html.classList.add("rounded");
this.id = makeid(8);
this.html.id = this.id;
// Calendar Header
let month = document.createElement('div');
month.classList.add("month");
let m_header = document.createElement('ul');
let prev_btn = document.createElement('li');
prev_btn.classList.add("prev");
prev_btn.id = this.id + "-prev";
prev_btn.innerHTML = "&#10094;";
m_header.appendChild(prev_btn);
let next_btn = document.createElement('li');
next_btn.classList.add("next");
next_btn.id = this.id + "-next";
next_btn.innerHTML = "&#10095;";
m_header.appendChild(next_btn);
let month_text = document.createElement('li');
month_text.innerHTML = "Август 2025";
m_header.appendChild(month_text);
month.appendChild(m_header);
this.html.appendChild(month);
// Week header
let weekdays = document.createElement('ul');
weekdays.classList.add("weekdays");
const days = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
for (const day of days) {
let day_li = document.createElement('li');
day_li.innerHTML = day;
weekdays.appendChild(day_li);
}
this.html.appendChild(weekdays);
let day_table = document.createElement('ul');
day_table.classList.add("days");
const current = 10;
for (const day of [...Array(31).keys()]) {
let day_li = document.createElement('li');
if (day+1 == current) {
let day_sp = document.createElement('span');
day_sp.classList.add("active");
day_sp.innerHTML = day+1;
day_li.appendChild(day_sp);
} else {
day_li.innerHTML = day+1;
}
day_table.appendChild(day_li);
}
this.html.appendChild(day_table);
}
}