Initial commit

This commit is contained in:
marc
2025-03-09 20:00:54 +01:00
commit efd26ce19d
118 changed files with 78086 additions and 0 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake . --impure

1
.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
**/**.pyc

0
.projectile Normal file
View File

26
README.org Normal file
View File

@@ -0,0 +1,26 @@
#+title: Readme
* Tasques
** TODO Refactor organitzatiu
* Idees
** Jams
*** Properes jams
**** Data i lloc de les properes jams
**** Info dels temes que es tocaràn (a la slow jam)
*** Live jam
**** Quin tema està sonant ara
*** Historial de jams
**** Veure a cada jam passada quins temes s'han tocat
** Temes
*** Navegació
**** Cerca de temes
***** Per nom, tonalitat, compàs, tipus, (shazam!?!?!)
***** Per tocats recentment
**** Informació
***** Nom
***** Tonalitat i compàs
***** Vegades que s'ha tocat
* Referències
Fonts d'inspiració i altres materials de referència per fer la web de folkugat
** Simple Site
https://github.com/tataraba/simplesite

35
custom-python.nix Normal file
View File

@@ -0,0 +1,35 @@
{ pkgs }:
rec {
# Custom python package
jinja2-fragments = pkgs.python311Packages.buildPythonPackage rec {
pname = "jinja2_fragments";
version = "1.2.1";
format = "wheel";
src = pkgs.python311Packages.fetchPypi rec {
inherit pname version format;
sha256 = "c9255e5aadd2cb4e3fbd27530370af0b728a522f139455008febc5145d819f84";
dist = python;
python = "py3";
};
propagatedBuildInputs = [
];
};
pytailwindcss = pkgs.python311Packages.buildPythonPackage rec {
pname = "pytailwindcss";
version = "0.2.0";
format = "wheel";
src = pkgs.python311Packages.fetchPypi rec {
inherit pname version format;
sha256 = "30e7bd3b78de19a39a7e66329db02d393ee540f010924397b4d780e85041fae4";
dist = python;
python = "py3";
};
propagatedBuildInputs = [
];
};
}

53
flake.nix Normal file
View File

@@ -0,0 +1,53 @@
{
description = "Development flake for the HTMX folkugat web";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = nixpkgs.legacyPackages.${system};
python = pkgs.python311;
pythonPackages = pkgs.python311Packages;
# custom-python = import ./custom-python.nix { inherit pkgs; };
projectDependencies = with pythonPackages; [
# API
fastapi
jinja2
uvicorn
# Auth
pyjwt
# Search
levenshtein
# Test
pytest
];
in {
devShells.default = pkgs.mkShell {
nativeBuildInputs = [ pkgs.bashInteractive ];
buildInputs = with pkgs; [
# Core python dependencies
python
pythonPackages.pip
pythonPackages.virtualenv
# Other dependencies
nodePackages_latest.tailwindcss
# IDE tools
pythonPackages.pylsp-mypy
pythonPackages.isort
pythonPackages.autopep8
nodePackages.pyright
# Development tools
black
pythonPackages.ipython
pythonPackages.pytest
pythonPackages.setuptools
] ++ projectDependencies;
};
});
}

0
folkugat_web/__init__.py Normal file
View File

View File

@@ -0,0 +1,6 @@
from ._router import router
from .auth import *
from .index import *
from .sessions import *
from .tema import *
from .temes import *

View File

@@ -0,0 +1,4 @@
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter(default_response_class=HTMLResponse)

34
folkugat_web/api/auth.py Normal file
View File

@@ -0,0 +1,34 @@
from typing import Annotated, Optional
from fastapi import Cookie, Form, Request
from folkugat_web.api import router
from folkugat_web.config import auth as config
from folkugat_web.fragments import nota
from folkugat_web.services import auth as service
@router.get("/api/nota")
def nota_input(request: Request, value: Optional[str] = None):
return nota.input(request, value)
@router.post("/api/nota")
def login(request: Request, value: Annotated[Optional[str], Form()] = None,
nota_folkugat: Annotated[Optional[str], Cookie()] = None):
logged_in = service.logged_in(nota_folkugat)
new_login = service.login(value)
if new_login and not logged_in:
response = nota.nota(request)
response.set_cookie(key=config.COOKIE_NAME,
value=service.build_token(),
max_age=config.COOKIE_MAX_AGE)
else:
response = nota.footer(request, value, logged_in)
return response
@router.post("/api/logout")
def logout(request: Request):
response = nota.nota(request)
response.delete_cookie(key=config.COOKIE_NAME)
return response

18
folkugat_web/api/index.py Normal file
View File

@@ -0,0 +1,18 @@
from fastapi import Request
from folkugat_web.api import router
from folkugat_web.services import auth
from folkugat_web.templates import templates
@router.get("/")
def index(request: Request, logged_in: auth.LoggedIn):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat",
"content": "/api/content/sessions",
"logged_in": logged_in,
"animate": True,
}
)

View File

@@ -0,0 +1 @@
from . import editor, index, sessio

View File

@@ -0,0 +1,42 @@
import datetime
from typing import Annotated, Optional
from fastapi import Form, Request
from folkugat_web.api import router
from folkugat_web.fragments import sessions
from folkugat_web.services import auth
@router.post("/api/sessions/editor/")
def insert_row(request: Request, _: auth.RequireLogin):
return sessions.sessions_editor_insert_row(request)
@router.get("/api/sessions/editor/{session_id}/")
def editor_row(request: Request, session_id: int, _: auth.RequireLogin):
return sessions.sessions_editor_row(request, session_id=session_id)
@router.put("/api/sessions/editor/{session_id}/")
def modify_session(
request: Request, session_id: int,
_: auth.RequireLogin,
date: Annotated[datetime.date, Form()],
start_time: Annotated[datetime.time, Form()],
end_time: Annotated[datetime.time, Form()],
venue_name: Annotated[Optional[str], Form()] = None,
venue_url: Annotated[Optional[str], Form()] = None,
):
session_date = sessions.model.Session(id=session_id, date=date, start_time=start_time, end_time=end_time,
venue=sessions.model.SessionVenue(name=venue_name, url=venue_url))
return sessions.sessions_editor_post_row(request, session_date)
@router.delete("/api/sessions/editor/{session_id}/")
def delete_date(session_id: int, _: auth.RequireLogin):
return sessions.sessions_editor_delete_row(session_id)
@router.get("/api/sessions/editor/{session_id}/edita")
def editor_row_editing(request: Request, session_id: int, _: auth.RequireLogin):
return sessions.sessions_editor_row_editing(request, session_id)

View File

@@ -0,0 +1,48 @@
from fastapi import Request
from folkugat_web.api import router
from folkugat_web.config import calendari as calendari_conf
from folkugat_web.fragments import live, sessions
from folkugat_web.services import auth
from folkugat_web.templates import templates
@router.get("/sessions")
def page(request: Request, logged_in: auth.LoggedIn):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat",
"content": "/api/content/sessions",
"logged_in": logged_in,
"animate": False,
}
)
@router.get("/api/content/sessions")
def content(request: Request, logged_in: auth.LoggedIn):
return sessions.sessions_pagina(request, logged_in)
@router.get("/api/sessions/upcoming")
def calendari(
request: Request,
logged_in: auth.LoggedIn,
limit: int = calendari_conf.CALENDARI_PAGING_CONFIG.initial_items,
):
return sessions.sessions_calendari(request=request, limit=limit, logged_in=logged_in)
@router.get("/api/sessions/history")
def history(
request: Request,
logged_in: auth.LoggedIn,
limit: int = calendari_conf.HISTORY_PAGING_CONFIG.initial_items,
):
return sessions.sessions_historial(request=request, limit=limit, logged_in=logged_in)
@router.get("/api/sessions/live")
def get_live(request: Request):
return live.sessio_en_directe(request)

View File

@@ -0,0 +1,41 @@
from fastapi import Request
from folkugat_web.api import router
from folkugat_web.fragments import live, sessions
from folkugat_web.services import auth
from folkugat_web.templates import templates
@router.get("/sessio/{session_id}")
def page(
request: Request,
session_id: int,
logged_in: auth.LoggedIn,
):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat",
"content": f"/api/content/sessio/{session_id}",
"logged_in": logged_in,
}
)
@router.get("/api/content/sessio/{session_id}")
def contingut(
request: Request,
session_id: int,
logged_in: auth.LoggedIn,
):
return sessions.sessio(request, session_id, logged_in)
@router.put("/api/sessio/{session_id}/live")
def set_live(request: Request, session_id: int, _: auth.RequireLogin):
return live.start_live_session(request=request, session_id=session_id)
@router.delete("/api/sessio/{session_id}/live")
def stop_live(request: Request, session_id: int, _: auth.RequireLogin):
return live.stop_live_session(request=request, session_id=session_id)

View File

@@ -0,0 +1 @@
from . import editor, index

View File

@@ -0,0 +1,23 @@
from fastapi import Request
from folkugat_web.api import router
from folkugat_web.fragments import tema
from folkugat_web.services import auth
@router.get("/api/tema/{tema_id}/editor/title")
def title_editor(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
):
return tema.title_editor(request=request, logged_in=logged_in, tema_id=tema_id)
@router.get("/api/tema/{tema_id}/editor/lyric/{lyric_idx}")
def lyric_editor(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
lyric_idx: int,
):
return tema.lyric_editor(request=request, logged_in=logged_in, tema_id=tema_id, lyric_idx=lyric_idx)

View File

@@ -0,0 +1,84 @@
from typing import Annotated
from fastapi import Request
from fastapi.params import Form
from fastapi.responses import HTMLResponse
from folkugat_web.api import router
from folkugat_web.fragments import tema, temes
from folkugat_web.model import temes as model
from folkugat_web.services import auth
from folkugat_web.services.temes import write as temes_w
from folkugat_web.templates import templates
@router.get("/tema/{tema_id}")
def page(request: Request, logged_in: auth.LoggedIn, tema_id: int):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat",
"content": f"/api/tema/{tema_id}",
"logged_in": logged_in,
}
)
@router.get("/api/tema/{tema_id}")
def contingut(request: Request, logged_in: auth.LoggedIn, tema_id: int):
return temes.tema(request, tema_id, logged_in)
@router.get("/api/tema/{tema_id}/title")
def title(request: Request, logged_in: auth.LoggedIn, tema_id: int):
return tema.title(request=request, tema_id=tema_id, logged_in=logged_in)
@router.put("/api/tema/{tema_id}/title")
def set_title(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
title: Annotated[str, Form()],
):
new_tema = temes_w.update_title(tema_id=tema_id, title=title)
return tema.title(request=request, tema=new_tema, logged_in=logged_in)
@router.get("/api/tema/{tema_id}/lyric/{lyric_idx}")
def lyric(request: Request, logged_in: auth.LoggedIn, tema_id: int, lyric_idx: int):
return tema.lyric(request=request, logged_in=logged_in, tema_id=tema_id, lyric_idx=lyric_idx)
@router.put("/api/tema/{tema_id}/lyric/{lyric_idx}")
def set_lyric(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
lyric_idx: int,
title: Annotated[str, Form()],
lyric: Annotated[str, Form()],
):
new_lyric = model.Lyrics(title=title, content=lyric.strip())
temes_w.update_lyric(tema_id=tema_id, lyric_idx=lyric_idx, lyric=new_lyric)
return tema.lyric(request=request, logged_in=logged_in, tema_id=tema_id, lyric_idx=lyric_idx)
@router.post("/api/tema/{tema_id}/lyric")
def add_lyric(
request: Request,
logged_in: auth.RequireLogin,
tema_id: int,
):
new_tema = temes_w.add_lyric(tema_id=tema_id)
lyric_idx = len(new_tema.lyrics) - 1
return tema.lyric_editor(request=request, logged_in=logged_in, tema_id=tema_id, lyric_idx=lyric_idx)
@router.delete("/api/tema/{tema_id}/lyric/{lyric_idx}")
def delete_lyric(
tema_id: int,
lyric_idx: int,
):
temes_w.delete_lyric(tema_id=tema_id, lyric_idx=lyric_idx)
return HTMLResponse()

View File

@@ -0,0 +1 @@
from . import index

View File

@@ -0,0 +1,29 @@
from fastapi import Request
from folkugat_web.api import router
from folkugat_web.fragments import temes
from folkugat_web.services import auth
from folkugat_web.templates import templates
@router.get("/temes")
def page(request: Request, logged_in: auth.LoggedIn):
return templates.TemplateResponse(
"index.html",
{
"request": request,
"page_title": "Folkugat",
"content": "/api/content/temes",
"logged_in": logged_in,
"animate": False,
}
)
@router.get("/api/content/temes")
def content(request: Request, logged_in: auth.LoggedIn):
return temes.temes_pagina(request, logged_in)
@router.get("/api/temes/busca")
def busca(request: Request, query: str, logged_in: auth.LoggedIn):
return temes.temes_busca(request, query, logged_in)

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 56 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,106 @@
const pdfjsLib = await import('./pdf/pdf.mjs');
const pdfjsWorker = await import('./pdf/pdf.worker.mjs');
// The workerSrc property shall be specified.
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
export function displayPdf(options) {
var pdfDoc = null,
pageNum = 1,
pageRendering = false,
pageNumPending = null,
ctx = options.canvas.getContext('2d');
/**
* Get page info from document, resize canvas accordingly, and render page.
* @param num Page number.
*/
function renderPage(num) {
pageRendering = true;
// Using promise to fetch the page
pdfDoc.getPage(num).then(function(page) {
var canvasWidth = options.canvas.getBoundingClientRect().width,
pageWidth = page.getViewport({scale: 1.0}).width,
scale = canvasWidth/pageWidth,
viewport = page.getViewport({scale: scale});
options.canvas.height = viewport.height;
options.canvas.width = viewport.width;
// Render PDF page into canvas context
var renderContext = {
canvasContext: ctx,
viewport: viewport
};
var renderTask = page.render(renderContext);
// Wait for rendering to finish
renderTask.promise.then(function() {
pageRendering = false;
if (pageNumPending !== null) {
// New page rendering is pending
renderPage(pageNumPending);
pageNumPending = null;
}
});
});
// Update page counters
options.pageNum.textContent = num;
}
/**
* If another page rendering in progress, waits until the rendering is
* finised. Otherwise, executes rendering immediately.
*/
function queueRenderPage(num) {
if (pageRendering) {
pageNumPending = num;
} else {
renderPage(num);
}
}
/**
* Re-render page (e.g. on resize)
*/
function reRender() {
queueRenderPage(pageNum);
}
document.defaultView.addEventListener('resize', reRender)
/**
* Displays previous page.
*/
function onPrevPage() {
if (pageNum <= 1) {
return;
}
pageNum--;
queueRenderPage(pageNum);
}
options.prevPage.addEventListener('click', onPrevPage);
/**
* Displays next page.
*/
function onNextPage() {
if (pageNum >= pdfDoc.numPages) {
return;
}
pageNum++;
queueRenderPage(pageNum);
}
options.nextPage.addEventListener('click', onNextPage);
/**
* Asynchronously downloads PDF.
*/
pdfjsLib.getDocument(options.url).promise.then(function(pdfDoc_) {
pdfDoc = pdfDoc_;
options.pageCount.textContent = pdfDoc.numPages;
// Initial/first page rendering
renderPage(pageNum);
});
}

View File

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,7 @@
<div id="content">
<div hx-get="{{ content }}"
hx-trigger="load"
hx-target="#content"
hx-swap="innerHTML">
</div>
</div>

View File

@@ -0,0 +1,10 @@
<div id="footer">
<hr class="h-px my-3 bg-beige border-0 ">
<div id="nota" class="flex items-center justify-center">
<div hx-post="/api/nota"
hx-trigger="load"
hx-target="#nota"
hx-swap="innerHTML">
</div>
</div>
</div>

View File

@@ -0,0 +1,5 @@
<div class="h-4/5 min-h-[400px] flex flex-col items-center justify-center">
<img src = "{{ url_for('static', path='img/folkugat.svg') }}" class="{% if animate %} opacity-0 animate-fade-in-one {% endif %} m-3" width="100" alt="Folkugat"/>
<h1 class="text-3xl sm:text-8xl text-beige m-3 {% if animate %} opacity-0 animate-fade-in-one {% endif %}">{{ page_title }}</h1>
<h2 class="text-center sm:text-3xl m-6 {% if animate %} opacity-0 animate-fade-in-two {% endif %}">Sessions de folk a Sant Cugat</h2>
</div>

View File

@@ -0,0 +1,22 @@
{% if session %}
<div class="bg-beige text-white mt-2 py-2 text-xl overflow-hidden">
<a href="/sessio/{{ session.id }}">
<div class="animate-marquee whitespace-nowrap">
{% for _ in (1, 2, 3) %}
<span class="inline-block mx-4">
Sessió en directe
</span>
<span class="inline-block mx-4">
</span>
<span class="inline-block mx-4">
Està sonant <i>Pasdoble de Muntanya</i>
</span>
<span class="inline-block mx-4">
</span>
{% endfor %}
</div>
</a>
</div>
{% endif %}

View File

@@ -0,0 +1,35 @@
<hr class="h-px bg-beige border-0">
<ul class="flex flex-col min-[450px]:flex-row items-center justify-center">
{% if menu_selected_id == Pages.Sessions %}
<li class="p-2 text-beige">
<button>
{% else %}
<li class="p-2">
<button hx-get="/api/content/sessions"
hx-replace-url="/sessions"
hx-target="#content">
{% endif %}
Sessions
</button>
</li>
{% if menu_selected_id == Pages.Temes %}
<li class="p-2 text-beige">
<button>
{% else %}
<li class="p-2">
<button hx-get="/api/content/temes"
hx-replace-url="/temes"
hx-target="#content">
{% endif %}
Temes
</button>
</li>
</ul>
<hr class="h-px bg-beige border-0">
<div hx-get="/api/sessions/live"
hx-swap="innerHTML"
hx-trigger="load, every 20s, reload-marquee from:body">
</div>

View File

@@ -0,0 +1,32 @@
{% if logged_in %}
<button class="p-12"
hx-post="/api/logout"
hx-target="#nota">
😎
</button>
{% else %}
<button class="p-12"
hx-get="/api/nota?value={{value or ''}}"
hx-target="#nota"
hx-swap="innerHTML"
hx-include="#nota-input">
</button>
{% if value %}
<div class="p-12">
{{ value }}
</div>
<button class="p-12"
hx-get="/api/nota?value={{value or ''}}"
hx-target="#nota"
hx-swap="innerHTML"
hx-include="#nota-input">
</button>
{% endif %}
{% endif %}

View File

@@ -0,0 +1,20 @@
<button class="p-12"
hx-post="/api/nota"
hx-target="#nota"
hx-swap="innerHTML"
hx-include="#nota-input"
hx-trigger="clicked, keyup[keyCode==13] from:#nota-input">
</button>
<input id="nota-input" type="text" name="value" value="{{value or ''}}"
class="rounded border border-yellow-50 focus:outline-none
text-yellow-50 text-center
animate-grow max-w-xs
bg-brown p-1 m-1">
<button class="p-12"
hx-post="/api/nota"
hx-target="#nota"
hx-swap="innerHTML"
hx-include="#nota-input">
</button>

View File

@@ -0,0 +1,5 @@
<div hx-post="/api/nota"
hx-trigger="load"
hx-target="#nota"
hx-swap="innerHTML">
</div>

View File

@@ -0,0 +1,40 @@
<!-- PDF Viewer -->
<script type="module">
const pdfViewer = await import("{{ url_for('static', path='js/pdf_viewer.js') }}");
const options = {
url: '{{ pdf_url }}',
// PDF Canvas (where the pdf will be displayed)
canvas: document.getElementById('the-canvas'),
// Navigation elements
prevPage: document.getElementById('prev'),
nextPage: document.getElementById('next'),
// Page elements
pageCount: document.getElementById('page_count'),
pageNum: document.getElementById('page_num'),
}
pdfViewer.displayPdf(options);
</script>
<div class="flex flex-col items-center justify-center">
<!-- <div class="text-beige"> -->
<!-- <a href="{{ pdf_url }}" -->
<!-- target="_blank"> -->
<!-- <i class="fa fa-file-pdf" aria-hidden="true"></i> -->
<!-- obrir -->
<!-- </a> -->
<!-- </div> -->
<div class="flex flex-row flex-nowrap items-center justify-center w-full">
<button id="prev" class="flex-none text-xl text-beige mx-3">
<i class="fa fa-chevron-left" aria-hidden="true"></i>
</button>
<canvas class="flex-auto m-2 w-1/2 max-w-3xl"
id="the-canvas">
</canvas>
<button id="next" class="flex-none text-xl text-beige mx-3">
<i class="fa fa-chevron-right" aria-hidden="true"></i>
</button>
</div>
<div>
<span><span id="page_num">?</span> / <span id="page_count">?</span></span>
</div>
</div>

View File

@@ -0,0 +1,6 @@
<button title="Inicia una sessió"
class="text-beige mx-1"
hx-put="/api/sessio/{{ session.id }}/live"
hx-swap="outerHTML">
<i class="fa fa-play" aria-hidden="true"></i>
</button>

View File

@@ -0,0 +1,6 @@
<button title="Atura una sessió"
class="text-beige mx-1"
hx-delete="/api/sessio/{{ session.id }}/live"
hx-swap="outerHTML">
<i class="fa fa-stop" aria-hidden="true"></i>
</button>

View File

@@ -0,0 +1,25 @@
{% include "fragments/menu.html" %}
<div class="p-12 text-center">
<h3 class="text-3xl text-beige p-4">Calendari</h3>
{% if logged_in %}
<button title="Afegeix una sessió"
class="text-beige m-2"
hx-post="/api/sessions/editor/"
hx-target="#{{ calendar_list_id }}"
hx-swap="beforeend transition:true">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix una sessió
</button>
{% endif %}
<div hx-get="/api/sessions/upcoming"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
</div>
<h3 class="text-3xl text-beige p-4">Historial</h3>
<div hx-get="/api/sessions/history"
hx-trigger="load"
hx-target="this"
hx-swap="innerHTML">
</div>
</div>

View File

@@ -0,0 +1,33 @@
{% include "fragments/menu.html" %}
<div class="flex justify-center">
<div class="m-12 grow max-w-4xl text-center">
<h3 class="text-3xl p-4">
{% set dn = date_names(session.date) %}
{{ dn.day_name }} {{ dn.day }} {{ dn.month_name }} de {{ dn.year }}
</h3>
{% if logged_in %}
{% if session.is_live %}
{% include "fragments/sessions/live/stop.html" %}
{% else %}
{% include "fragments/sessions/live/start.html" %}
{% endif %}
{% endif %}
<div class="text-left">
<h4 class="text-xl text-beige">Horari i lloc</h4>
De {{ session.start_time.strftime("%H:%M") }}
a {{ session.end_time.strftime("%H:%M") }}
{% if session.venue.name %}
a
{% if session.venue.url %}
<a href="{{ session.venue.url }}"
class="text-beige"
target="_blank">
{{ session.venue.name }}
</a>
{% else %}
{{ session.venue.name }}
{% endif %}
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,24 @@
<p>
{% if session == None %}
<a> L'últim Dimecres de cada mes </a>
{% else %}
<a>
{% set dn = date_names(session.date) %}
{{ dn.day_name }} {{ dn.day }} {{ dn.month_name }}
</a>
de {{ session.start_time.strftime("%H:%M") }}
a {{ session.end_time.strftime("%H:%M") }}
{% if session.venue.name %}
a {{ session.venue.name }}
<!-- {% if session.venue.url %} -->
<!-- <a href="{{ session.venue.url }}" -->
<!-- class="text-beige" -->
<!-- target="_blank"> -->
<!-- {{ session.venue.name }} -->
<!-- </a> -->
<!-- {% else %} -->
<!-- {{ session.venue.name }} -->
<!-- {% endif %} -->
{% endif %}
{% endif %}
</p>

View File

@@ -0,0 +1,28 @@
<ol id="{{ session_list_id }}"
class="flex flex-col items-center justify-center">
{% for session in sessions %}
{% include "fragments/sessions/session_row.html" %}
{% endfor %}
{% if more_sessions or less_sessions %}
<li>
{% if more_sessions %}
<button title="Mostra més sessions"
class="text-beige mx-2"
hx-get="{{ get_sessions_url }}?limit={{ more_sessions }}"
hx-target="#{{ session_list_id }}"
hx-swap="outerHTML">
<i class="fa fa-caret-down" aria-hidden="true"></i>
</button>
{% endif %}
{% if less_sessions %}
<button title="Mostra menys sessions"
class="text-beige mx-2"
hx-get="{{ get_sessions_url }}?limit={{ less_sessions }}"
hx-target="#{{ session_list_id }}"
hx-swap="outerHTML">
<i class="fa fa-caret-up" aria-hidden="true"></i>
</button>
{% endif %}
</li>
{% endif %}
</ol>

View File

@@ -0,0 +1,44 @@
<li class="border rounded border-beige
flex flex-row grow
p-2 m-2 w-full max-w-xl
relative"
id="session-row-{{session.id}}">
<a href="/session/{{session.id}}">
<div class="flex flex-row grow justify-center pl-10">
<div class="flex-1">
{% include "fragments/sessions/session_date.html" %}
</div>
<div class="ml-auto">
<a title="Més informació"
class="text-beige mx-1"
href="/sessio/{{session.id}}/">
<i class="fa fa-info-circle" aria-hidden="true"></i>
</a>
{% if logged_in %}
<button title="Edita la sessió"
class="text-beige mx-1"
hx-get="/api/sessions/editor/{{session.id}}/edita"
hx-target="#session-row-{{session.id}}"
hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
<button title="Esborra la sessió"
class="text-beige mx-1"
hx-delete="/api/sessions/editor/{{session.id}}/"
hx-target="#session-row-{{session.id}}"
hx-swap="outerHTML swap:0.5s">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
{% endif %}
</div>
</div>
{% if session.id == next_session_id %}
<div class="absolute -top-5 -left-6
bg-beige text-xs font-bold
px-2 py-1
transition-opacity duration-200
rounded">
Propera<br>sessió
</div>
{% endif %}
</li>

View File

@@ -0,0 +1,96 @@
<li id="session-editor-row-{{session.id}}"
class="border rounded border-beige
flex flex-col grow
py-2 px-10 m-2 w-full max-w-xl">
<form>
<div class="flex flex-row justify-end items-center">
<button title="Aplica els canvis"
class="text-beige mx-2"
hx-put="/api/sessions/editor/{{session.id}}/"
hx-target="#session-editor-row-{{session.id}}"
hx-swap="outerHTML">
<i class="fa fa-check" aria-hidden="true"></i>
</button>
<button title="Descarta els canvis"
class="text-beige mx-2"
hx-get="/api/sessions/editor/{{session.id}}/"
hx-target="#session-editor-row-{{session.id}}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="flex flex-col items-start pl-5">
<div>
<label for="session-editor-row-{{session.id}}-date">
Data:
</label>
<input type="date" name="date"
value="{{ session.date.strftime('%Y-%m-%d') }}"
min="2022-01-01"
max="2050-12-31"
class="border-none focus:outline-none
text-yellow-50 text-center
bg-brown p-0 m-0
session-editor-{{session.id}}"
/>
</div>
<div>
<label for="session-editor-row-{{session.id}}-start-time">
Hora d'inici:
</label>
<input type="time" name="start_time"
value="{{ session.start_time.strftime('%H:%M') }}"
step="60"
class="border-none focus:outline-none
text-yellow-50 text-center
bg-brown p-0 m-0
session-editor-{{session.id}}"
/>
</div>
<div>
<label for="session-editor-row-{{session.id}}-end-time">
Hora final:
</label>
<input type="time" name="end_time"
value="{{ session.end_time.strftime('%H:%M') }}"
step="60"
class="border-none focus:outline-none
text-yellow-50 text-center
bg-brown p-0 m-0
session-editor-{{session.id}}"
/>
</div>
<div>
<label for="session-editor-row-{{session.id}}-venue-name">
Lloc:
</label>
<input list="llocs" name="venue_name"
placeholder="nom del lloc"
value="{{ session.venue.name or "" }}"
class="border border-yellow-50 focus:outline-none
rounded
text-yellow-50 text-center
bg-brown p-0 m-0
session-editor-{{session.id}}"
/>
<datalist id="llocs">
<option value="la FEM">
<option value="Cal Temerari">
</datalist>
</div>
<div>
<label for="session-editor-row-{{session.id}}-venue-url">
Link:
</label>
<input placeholder="URL del lloc" name="venue_url"
value="{{ session.venue.url or "" }}"
class="border border-yellow-50 focus:outline-none
rounded
text-yellow-30 text-center
bg-brown p-0 m-0 my-1
session-editor-{{session.id}}"
/>
</div>
</div>
</form>
</li>

View File

@@ -0,0 +1,35 @@
<form id="tema-lyric-{{ lyric_idx }}">
<h5 class="text-sm text-beige text-right">
<input name="title"
placeholder="Nom de la lletra"
value="{{ lyric.title }}"
class="border border-beige focus:outline-none
rounded
bg-brown px-2 "
/>
<button title="Desa els canvis"
class="mx-1"
hx-put="/api/tema/{{ tema.id }}/lyric/{{ lyric_idx }}"
hx-target="#tema-lyric-{{ lyric_idx }}"
hx-swap="outerHTML">
<i class="fa fa-check" aria-hidden="true"></i>
</button>
<button title="Descarta els canvis"
class="mx-1"
hx-get="/api/tema/{{ tema.id }}/lyric/{{ lyric_idx }}"
hx-target="#tema-lyric-{{ lyric_idx }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
<textarea name="lyric"
placeholder="Lletra"
rows="{{ lyric.content.count('\n') + 1 }}"
class="border border-beige focus:outline-none
w-full text-center
rounded
bg-brown p-2 m-0">
{{ lyric.content }}
</textarea>
</form>

View File

@@ -0,0 +1,26 @@
<div class="flex flex-row flex-wrap justify-center"
id="tema-title">
<form>
<input name="title"
placeholder="Nom del tema"
value="{{ tema.title }}"
class="border border-beige focus:outline-none
rounded text-3xl
bg-brown p-2 m-0"
/>
<button title="Desa"
class="text-beige text-3xl mx-1"
hx-put="/api/tema/{{ tema.id }}/title"
hx-target="#tema-title"
hx-swap="outerHTML">
<i class="fa fa-check" aria-hidden="true"></i>
</button>
<button title="Descarta"
class="text-beige text-3xl mx-1"
hx-get="/api/tema/{{ tema.id }}/title"
hx-target="#tema-title"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
</form>
</div>

View File

@@ -0,0 +1,21 @@
<li class="flex flex-row items-start">
<div class="p-2 m-2 text-beige border border-beige rounded-md">
<a href="{{ link.url }}" target="_blank">
{% if link.subtype == LinkSubtype.SPOTIFY %}
{% include "icons/spotify.svg" %}
{% elif link.subtype == LinkSubtype.YOUTUBE %}
{% include "icons/youtube.svg" %}
{% elif link.subtype == LinkSubtype.PDF %}
{% include "icons/pdf.svg" %}
{% elif link.type == LinkType.AUDIO %}
{% include "icons/notes.svg" %}
{% else %}
{% include "icons/link.svg" %}
{% endif %}
</a>
</div>
<div class="my-2">
<p class="text-sm text-beige">Partitura</p>
<p>Hola</p>
</div>
</li>

View File

@@ -0,0 +1,27 @@
{% if logged_in or tema.links %}
<h4 class="text-xl text-beige">Enllaços</h4>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
{% endif %}
{% if tema.links %}
{% for link in tema.links %}
<ul class="flex flex-col justify-center"
id="new-link-target">
{% set link_idx = loop.index0 %}
{% include "fragments/tema/link.html" %}
</ul>
{% endfor %}
{% endif %}
{% if logged_in %}
<div class="flex flex-row my-2 justify-end">
<button title="Afegeix una lletra"
class="text-sm text-beige text-right"
hx-post="/api/tema/{{ tema.id }}/link"
hx-target="#new-link-target"
hx-swap="beforebegin">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix un enllaç
</button>
</div>
{% endif %}

View File

@@ -0,0 +1,25 @@
<div id="tema-lyric-{{ lyric_idx }}">
<h5 class="text-sm text-beige text-right">
{{ lyric.title }}
{% if logged_in %}
<button title="Modifica la lletra"
class="mx-1"
hx-get="/api/tema/{{ tema.id }}/editor/lyric/{{ lyric_idx }}"
hx-target="#tema-lyric-{{ lyric_idx }}"
hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
<button title="Esborra la lletra"
class="mx-1"
hx-delete="/api/tema/{{ tema.id }}/lyric/{{ lyric_idx }}"
hx-target="#tema-lyric-{{ lyric_idx }}"
hx-swap="outerHTML">
<i class="fa fa-times" aria-hidden="true"></i>
</button>
{% endif %}
</h5>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
<div class="text-center">
{{ lyric.content.replace('\n', '<br>') | safe }}
</div>
</div>

View File

@@ -0,0 +1,24 @@
{% if logged_in or tema.lyrics %}
<h4 class="text-xl text-beige">Lletra</h4>
{% endif %}
{% if tema.lyrics %}
{% for lyric in tema.lyrics %}
{% set lyric_idx = loop.index0 %}
{% include "fragments/tema/lyric.html" %}
{% endfor %}
{% endif %}
{% if logged_in %}
<div id="new-lyric-target"></div>
<div class="flex flex-row my-2 justify-end">
<button title="Afegeix una lletra"
class="text-sm text-beige text-right"
hx-post="/api/tema/{{ tema.id }}/lyric"
hx-target="#new-lyric-target"
hx-swap="beforebegin">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix una lletra
</button>
</div>
{% endif %}

View File

@@ -0,0 +1,34 @@
{% include "fragments/menu.html" %}
<div class="flex justify-center">
<div class="m-12 grow max-w-4xl text-center">
{% include "fragments/tema/title.html" %}
<div class="text-left">
<!-- {% if tema.scores() %} -->
<!-- temes -->
<!-- <h4 class="text-xl text-beige">Tema</h4> -->
<!-- <hr class="h-px mt-1 mb-3 bg-beige border-0"> -->
<!-- {% for file in tema.files %} -->
<!-- {% if file.type == FileType.PDF %} -->
<!-- {% set pdf_url = file.path %} -->
<!-- {% include "fragments/pdf_viewer.html" %} -->
<!-- {% endif %} -->
<!-- {% endfor %} -->
<!-- {% endif %} -->
{% include "fragments/tema/lyrics.html" %}
{% include "fragments/tema/links.html" %}
<!-- PROPERTIES -->
{% if tema.properties %}
<h4 class="text-xl mt-3 text-beige">Informació</h4>
<hr class="h-px mt-1 mb-3 bg-beige border-0">
{% for property in tema.properties %}
<p>
<i>{{ property.field.value.capitalize() }}</i>: {{ property.value }}
</p>
{% endfor %}
{% endif %}
</div>
</div>
</div>

View File

@@ -0,0 +1,13 @@
<div class="flex flex-row flex-wrap justify-center"
id="tema-title">
<h3 class="text-3xl p-4">{{ tema.title }}</h3>
{% if logged_in %}
<button title="Canvia el títol"
class="text-beige text-2xl"
hx-get="/api/tema/{{ tema.id }}/editor/title"
hx-target="#tema-title"
hx-swap="outerHTML">
<i class="fa fa-pencil" aria-hidden="true"></i>
</button>
{% endif %}
</div>

View File

@@ -0,0 +1,24 @@
{% include "fragments/menu.html" %}
<div class="p-12 text-center flex flex-col items-center justify-center">
<h3 class="text-3xl text-beige p-4">Temes</h3>
<button title="Afegeix un tema"
class="text-beige m-2">
<i class="fa fa-plus" aria-hidden="true"></i>
Afegeix un tema
</button>
<input type="text" name="query" value=""
placeholder="Busca una tema..."
class="rounded
text-yellow-50 bg-brown
border border-yellow-50
p-2 m-2"
hx-get="/api/temes/busca"
hx-trigger="revealed, keyup delay:500ms changed"
hx-target="#search-results">
<div class="flex justify-center">
<div id="search-results"
class="m-4 max-w-5xl
flex flex-wrap items-center justify-center">
</div>
</div>
</div>

View File

@@ -0,0 +1,44 @@
<div class="flex-none px-4 py-2 w-80 h-80 m-2 border-solid border rounded-md bg-yellow-50 text-brown">
<div class="flex flex-col h-full">
<div class="text-xl text-beige">
<a href="/tema/{{ tema.id }}">
{{ tema.title }}
</a>
</div>
<div class="grow"></div>
<div class="flex flex-row">
</div>
<ul class="flex flex-row items-center justify-center">
{% if tema.links %}
{% for link in tema.links %}
<li class="p-2 m-2 text-beige border border-beige rounded-md">
<a href="{{ link.url }}" target="_blank">
{% if link.type == LinkType.AUDIO %}
{% if link.subtype == LinkSubtype.SPOTIFY %}
{% include "icons/spotify.svg" %}
{% elif link.subtype == LinkSubtype.YOUTUBE %}
{% include "icons/youtube.svg" %}
{% else %}
{% include "icons/notes.svg" %}
{% endif %}
{% elif link.type == LinkType.SCORE %}
{% if link.subtype == LinkSubtype.PDF %}
{% include "icons/pdf.svg" %}
{% else %}
{% include "icons/link.svg" %}
{% endif %}
{% else %}
{% include "icons/link.svg" %}
{% endif %}
</a>
</li>
{% endfor %}
{% endif %}
</ul>
<!-- {% if logged_in %} -->
<!-- <div> -->
<!-- Edita -->
<!-- </div> -->
<!-- {% endif %} -->
</div>
</div>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-link-45deg" viewBox="0 0 16 16">
<path d="M4.715 6.542 3.343 7.914a3 3 0 1 0 4.243 4.243l1.828-1.829A3 3 0 0 0 8.586 5.5L8 6.086a1.002 1.002 0 0 0-.154.199 2 2 0 0 1 .861 3.337L6.88 11.45a2 2 0 1 1-2.83-2.83l.793-.792a4.018 4.018 0 0 1-.128-1.287z"/>
<path d="M6.586 4.672A3 3 0 0 0 7.414 9.5l.775-.776a2 2 0 0 1-.896-3.346L9.12 3.55a2 2 0 1 1 2.83 2.83l-.793.792c.112.42.155.855.128 1.287l1.372-1.372a3 3 0 1 0-4.243-4.243z"/>
</svg>

After

Width:  |  Height:  |  Size: 531 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-music-note-beamed" viewBox="0 0 16 16">
<path d="M6 13c0 1.105-1.12 2-2.5 2S1 14.105 1 13c0-1.104 1.12-2 2.5-2s2.5.896 2.5 2m9-2c0 1.105-1.12 2-2.5 2s-2.5-.895-2.5-2 1.12-2 2.5-2 2.5.895 2.5 2"/>
<path fill-rule="evenodd" d="M14 11V2h1v9zM6 3v10H5V3z"/>
<path d="M5 2.905a1 1 0 0 1 .9-.995l8-.8a1 1 0 0 1 1.1.995V3L5 4z"/>
</svg>

After

Width:  |  Height:  |  Size: 426 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-file-earmark-pdf-fill" viewBox="0 0 16 16">
<path d="M5.523 12.424c.14-.082.293-.162.459-.238a7.878 7.878 0 0 1-.45.606c-.28.337-.498.516-.635.572a.266.266 0 0 1-.035.012.282.282 0 0 1-.026-.044c-.056-.11-.054-.216.04-.36.106-.165.319-.354.647-.548m2.455-1.647c-.119.025-.237.05-.356.078a21.148 21.148 0 0 0 .5-1.05 12.045 12.045 0 0 0 .51.858c-.217.032-.436.07-.654.114m2.525.939a3.881 3.881 0 0 1-.435-.41c.228.005.434.022.612.054.317.057.466.147.518.209a.095.095 0 0 1 .026.064.436.436 0 0 1-.06.2.307.307 0 0 1-.094.124.107.107 0 0 1-.069.015c-.09-.003-.258-.066-.498-.256M8.278 6.97c-.04.244-.108.524-.2.829a4.86 4.86 0 0 1-.089-.346c-.076-.353-.087-.63-.046-.822.038-.177.11-.248.196-.283a.517.517 0 0 1 .145-.04c.013.03.028.092.032.198.005.122-.007.277-.038.465z"/>
<path fill-rule="evenodd" d="M4 0h5.293A1 1 0 0 1 10 .293L13.707 4a1 1 0 0 1 .293.707V14a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2m5.5 1.5v2a1 1 0 0 0 1 1h2zM4.165 13.668c.09.18.23.343.438.419.207.075.412.04.58-.03.318-.13.635-.436.926-.786.333-.401.683-.927 1.021-1.51a11.651 11.651 0 0 1 1.997-.406c.3.383.61.713.91.95.28.22.603.403.934.417a.856.856 0 0 0 .51-.138c.155-.101.27-.247.354-.416.09-.181.145-.37.138-.563a.844.844 0 0 0-.2-.518c-.226-.27-.596-.4-.96-.465a5.76 5.76 0 0 0-1.335-.05 10.954 10.954 0 0 1-.98-1.686c.25-.66.437-1.284.52-1.794.036-.218.055-.426.048-.614a1.238 1.238 0 0 0-.127-.538.7.7 0 0 0-.477-.365c-.202-.043-.41 0-.601.077-.377.15-.576.47-.651.823-.073.34-.04.736.046 1.136.088.406.238.848.43 1.295a19.697 19.697 0 0 1-1.062 2.227 7.662 7.662 0 0 0-1.482.645c-.37.22-.699.48-.897.787-.21.326-.275.714-.08 1.103z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-spotify" viewBox="0 0 16 16">
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0m3.669 11.538a.498.498 0 0 1-.686.165c-1.879-1.147-4.243-1.407-7.028-.77a.499.499 0 0 1-.222-.973c3.048-.696 5.662-.397 7.77.892a.5.5 0 0 1 .166.686zm.979-2.178a.624.624 0 0 1-.858.205c-2.15-1.321-5.428-1.704-7.972-.932a.625.625 0 0 1-.362-1.194c2.905-.881 6.517-.454 8.986 1.063a.624.624 0 0 1 .206.858m.084-2.268C10.154 5.56 5.9 5.419 3.438 6.166a.748.748 0 1 1-.434-1.432c2.825-.857 7.523-.692 10.492 1.07a.747.747 0 1 1-.764 1.288z"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" class="bi bi-youtube" viewBox="0 0 16 16">
<path d="M8.051 1.999h.089c.822.003 4.987.033 6.11.335a2.01 2.01 0 0 1 1.415 1.42c.101.38.172.883.22 1.402l.01.104.022.26.008.104c.065.914.073 1.77.074 1.957v.075c-.001.194-.01 1.108-.082 2.06l-.008.105-.009.104c-.05.572-.124 1.14-.235 1.558a2.007 2.007 0 0 1-1.415 1.42c-1.16.312-5.569.334-6.18.335h-.142c-.309 0-1.587-.006-2.927-.052l-.17-.006-.087-.004-.171-.007-.171-.007c-1.11-.049-2.167-.128-2.654-.26a2.007 2.007 0 0 1-1.415-1.419c-.111-.417-.185-.986-.235-1.558L.09 9.82l-.008-.104A31.4 31.4 0 0 1 0 7.68v-.123c.002-.215.01-.958.064-1.778l.007-.103.003-.052.008-.104.022-.26.01-.104c.048-.519.119-1.023.22-1.402a2.007 2.007 0 0 1 1.415-1.42c.487-.13 1.544-.21 2.654-.26l.17-.007.172-.006.086-.003.171-.007A99.788 99.788 0 0 1 7.858 2h.193zM6.4 5.209v4.818l4.157-2.408z"/>
</svg>

After

Width:  |  Height:  |  Size: 913 B

View File

@@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>
<title>{{ page_title }}</title>
<meta name="description" content="{{ page_description }}"/>
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"
integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA=="
crossorigin="anonymous"
referrerpolicy="no-referrer" />
<!-- Taiwind CSS -->
<link rel="stylesheet" href="{{ url_for('static', path='css/main.css') }}" type="text/css" />
<!-- HTMX -->
<script src="{{ url_for('static', path='js/htmx.min.js') }}"></script>
<!-- Favicon! -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', path='favicon/apple-touch-icon.png') }}">
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', path='favicon/favicon-32x32.png') }}">
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', path='favicon/favicon-16x16.png') }}">
<link rel="manifest" href="/site.webmanifest">
</head>
<body class="bg-brown text-yellow-50">
<div id="header">
{% include "fragments/header.html" %}
</div>
<div class="{% if animate %} animate-fade-in-three opacity-0 {% endif %}">
{% include "fragments/content.html" %}
{% include "fragments/footer.html" %}
</div>
</body>
</html>

View File

View File

@@ -0,0 +1,11 @@
import os
import random
import string
from datetime import timedelta
SESSION_DURATION = timedelta(minutes=30)
COOKIE_NAME = "nota_folkugat"
COOKIE_MAX_AGE = int(SESSION_DURATION.total_seconds())
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "hola")
JWT_SECRET = os.getenv("JWT_SECRET", "".join(random.choice(string.ascii_letters) for _ in range(32)))

View File

@@ -0,0 +1,21 @@
import dataclasses
CALENDAR_LIST_ID = "calendar-list"
HISTORY_LIST_ID = "history-list"
@dataclasses.dataclass
class PagingConfig:
initial_items: int
step: int
CALENDARI_PAGING_CONFIG = PagingConfig(
initial_items=3,
step=2
)
HISTORY_PAGING_CONFIG = PagingConfig(
initial_items=3,
step=2
)

View File

@@ -0,0 +1,23 @@
DAY_NAMES_CAT = {
0: "Dilluns",
1: "Dimarts",
2: "Dimecres",
3: "Dijous",
4: "Divendres",
5: "Dissabte",
6: "Diumenge",
}
MONTH_NAMES_CAT = {
1: "de Gener",
2: "de Febrer",
3: "de Març",
4: "d'Abril",
5: "de Maig",
6: "de Juny",
7: "de Juliol",
8: "de Agost",
9: "de Setembre",
10: "d'Octubre",
11: "de Novembre",
12: "de Desembre",
}

10
folkugat_web/config/db.py Normal file
View File

@@ -0,0 +1,10 @@
import os
from pathlib import Path
from folkugat_web.log import logger
DB_DIR = Path(os.getenv("DB_DIR", "/home/marc/tmp/folkugat")).resolve()
DB_FILE = DB_DIR / "folkugat.db"
DB_TEMES_DIR = DB_DIR / "temes"
logger.info(f"Using DB_DIR: {DB_DIR}")

View File

@@ -0,0 +1,10 @@
from pathlib import Path
from folkugat_web.log import logger
APP_DIR = Path(__file__).resolve().parent.parent
ASSETS_DIR = APP_DIR / "assets"
STATIC_DIR = ASSETS_DIR / "static"
TEMPLATES_DIR = ASSETS_DIR / "templates"
logger.info(f"Using APP_DIR: {APP_DIR}")

View File

@@ -0,0 +1 @@
LOGGED_IN_FOOTER = "😎"

View File

@@ -0,0 +1,8 @@
# Minimum ngram length to compute, search string shorther than this limit are ignored
MIN_NGRAM_LENGTH = 3
# Maximum number of insertions/deletions to check for when comparing strings
QUERY_NGRAM_RANGE = 2
# Maximum distance to show a match as the result of a search
SEARCH_DISTANCE_THRESHOLD = 0.25

View File

View File

@@ -0,0 +1 @@
from ._connection import get_connection, Connection

View File

@@ -0,0 +1,23 @@
import sqlite3
from contextlib import contextmanager
from typing import Iterator, Optional
from folkugat_web.config.db import DB_FILE
Connection = sqlite3.Connection
@contextmanager
def get_connection(con: Optional[Connection] = None) -> Iterator[Connection]:
if con:
yield con
else:
con = sqlite3.connect(DB_FILE)
try:
yield con
con.commit()
except Exception:
con.rollback()
raise
finally:
con.close()

View File

@@ -0,0 +1,8 @@
from folkugat_web.dal.sql import get_connection, sessions
from folkugat_web.dal.sql.temes import ddl as temes_ddl
def create_db():
with get_connection() as con:
sessions.create_db(con)
temes_ddl.create_db(con)

View File

@@ -0,0 +1,194 @@
import datetime
from typing import Optional
from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import sessions as model
from folkugat_web.model.sql import OrderCol, Range
from folkugat_web.typing import OptionalListOrValue as OLV
def create_db(con: Optional[Connection] = None):
with get_connection(con) as con:
create_sessions_table(con)
def drop_sessions_table(con: Connection):
query = "DROP TABLE IF EXISTS sessions"
cur = con.cursor()
cur.execute(query)
def create_sessions_table(con: Connection):
query = """
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY,
date TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
venue_name TEXT,
venue_url TEXT,
is_live BOOLEAN DEFAULT false
)
"""
cur = con.cursor()
cur.execute(query)
def _session_to_row(sessio: model.Session) -> dict:
return {
'id': sessio.id,
'date': sessio.date,
'start_time': sessio.start_time.isoformat(),
'end_time': sessio.end_time.isoformat(),
'venue_name': sessio.venue.name,
'venue_url': sessio.venue.url,
'is_live': sessio.is_live,
}
def _row_to_session(row: tuple) -> model.Session:
return model.Session(
id=row[0],
date=datetime.date.fromisoformat(row[1]),
start_time=datetime.time.fromisoformat(row[2]),
end_time=datetime.time.fromisoformat(row[3]),
venue=model.SessionVenue(
name=row[4],
url=row[5],
),
is_live=row[6],
)
def insert_session(session: model.Session, con: Optional[Connection] = None):
query = """
INSERT INTO sessions
(id, date, start_time, end_time, venue_name, venue_url, is_live)
VALUES
(:id, :date, :start_time, :end_time, :venue_name, :venue_url, :is_live)
RETURNING *
"""
data = _session_to_row(session)
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query, data)
row = cur.fetchone()
return _row_to_session(row)
def update_session(session: model.Session, con: Optional[Connection] = None):
query = """
UPDATE sessions SET
date = :date, start_time = :start_time, end_time = :end_time,
venue_name = :venue_name, venue_url = :venue_url, is_live = :is_live
WHERE id = :id
"""
data = _session_to_row(session)
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query, data)
def _filter_clause(session_id: Optional[int] = None,
date_range: Optional[Range[datetime.date]] = None,
is_live: Optional[bool] = None) -> tuple[str, dict]:
filter_clauses = []
filter_data = {}
if session_id is not None:
filter_clauses.append(f"id = :session_id")
filter_data["session_id"] = session_id
if date_range:
if ub := date_range.upper_bound():
operator = "<=" if ub[1] else "<"
filter_clauses.append(f"date {operator} :date_ub")
filter_data["date_ub"] = ub[0]
if lb := date_range.lower_bound():
operator = ">=" if lb[1] else ">"
filter_clauses.append(f"date {operator} :date_lb")
filter_data["date_lb"] = lb[0]
if is_live is not None:
filter_clauses.append(f"is_live = :is_live")
filter_data["is_live"] = is_live
if filter_clauses:
filter_clause_str = " AND ".join(filter_clauses)
filter_clause = f"WHERE {filter_clause_str}"
return filter_clause, filter_data
else:
return "", {}
def _order_clause(order_by: OLV[OrderCol[model.SessionCols]]) -> str:
if not order_by:
return ""
if not isinstance(order_by, list):
order_by = [order_by]
order_clauses = [f"{ocol.column.value} {ocol.order.value}" for ocol in order_by]
order_clauses_str = " ".join(order_clauses)
return f"ORDER BY {order_clauses_str}"
def get_sessions(session_id: Optional[int] = None,
date_range: Optional[Range[datetime.date]] = None,
is_live: Optional[bool] = None,
order_by: OLV[OrderCol[model.SessionCols]] = None,
limit: Optional[int] = None, offset: Optional[int] = None,
con: Optional[Connection] = None) -> list[model.Session]:
clauses = []
filter_clause, data = _filter_clause(session_id=session_id, date_range=date_range, is_live=is_live)
if filter_clause:
clauses.append(filter_clause)
if order_clause := _order_clause(order_by=order_by):
clauses.append(order_clause)
if limit is not None:
clauses.append("LIMIT :limit")
data["limit"] = limit
if offset is not None:
clauses.append("OFFSET :offset")
data["offset"] = offset
clauses_str = " ".join(clauses)
query = f"""
SELECT id, date, start_time, end_time, venue_name, venue_url, is_live
FROM sessions
{clauses_str}
"""
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query, data)
return list(map(_row_to_session, cur.fetchall()))
def delete_session_by_id(session_id: int, con: Optional[Connection] = None):
query = """
DELETE FROM sessions
WHERE id = :id
"""
data = dict(id=session_id)
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query, data)
def stop_live_sessions(con: Optional[Connection] = None):
query = """
UPDATE sessions SET is_live = false WHERE is_live = true
"""
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query)
def set_live_session(session_id: int, con: Optional[Connection] = None):
query = """
UPDATE sessions SET is_live = true WHERE id = :id
"""
data = dict(id=session_id)
with get_connection(con) as con:
stop_live_sessions(con=con)
cur = con.cursor()
cur.execute(query, data)

View File

View File

@@ -0,0 +1,38 @@
import datetime
import json
from folkugat_web.model import temes as model
def tema_to_row(tema: model.Tema) -> dict:
return {
'id': tema.id,
'title': tema.title,
'properties': json.dumps(list(map(lambda p: p.to_dict(), tema.properties))),
'links': json.dumps(list(map(lambda l: l.to_dict(), tema.links))),
'lyrics': json.dumps(list(map(lambda l: l.to_dict(), tema.lyrics))),
'alternatives': json.dumps(tema.alternatives),
'ngrams': json.dumps(tema.ngrams),
'modification_date': tema.modification_date.isoformat(),
'creation_date': tema.creation_date.isoformat(),
'hidden': 1 if tema.hidden else 0,
}
def cell_to_ngrams(cell: str) -> model.NGrams:
return {int(n): ngrams_ for n, ngrams_ in json.loads(cell).items()}
def row_to_tema(row: tuple) -> model.Tema:
return model.Tema(
id=row[0],
title=row[1],
properties=list(map(model.Property.from_dict, json.loads(row[2]))),
links=list(map(model.Link.from_dict, json.loads(row[3]))),
lyrics=list(map(model.Lyrics.from_dict, json.loads(row[4]))),
alternatives=json.loads(row[5]),
ngrams=cell_to_ngrams(row[6]),
modification_date=datetime.datetime.fromisoformat(row[7]),
creation_date=datetime.datetime.fromisoformat(row[8]),
hidden=bool(row[9]),
)

View File

@@ -0,0 +1,40 @@
from typing import Optional
from folkugat_web import data
from folkugat_web.dal.sql import Connection, get_connection
from .write import insert_tema
def create_db(con: Optional[Connection] = None):
with get_connection(con) as con:
drop_temes_table(con)
create_temes_table(con)
for tema in data.TEMES:
insert_tema(tema, con)
def drop_temes_table(con: Connection):
query = "DROP TABLE IF EXISTS temes"
cur = con.cursor()
cur.execute(query)
def create_temes_table(con: Connection):
query = """
CREATE TABLE IF NOT EXISTS temes (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
properties TEXT,
links TEXT,
lyrics TEXT,
alternatives TEXT,
ngrams TEXT,
creation_date TEXT NOT NULL,
modification_date TEXT NOT NULL,
hidden INTEGER NOT NULL
)
"""
cur = con.cursor()
cur.execute(query)

View File

@@ -0,0 +1,49 @@
from typing import Optional
from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import temes as model
from ._conversion import cell_to_ngrams, row_to_tema
TEMA_ID_TO_NGRAMS_CACHE = None
def get_tema_by_id(tema_id: int, con: Optional[Connection] = None) -> Optional[model.Tema]:
query = """
SELECT
id, title, properties, links, lyrics, alternatives, ngrams,
creation_date, modification_date, hidden
FROM temes
WHERE id = :id
"""
data = dict(id=tema_id)
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query, data)
row = cur.fetchone()
return row_to_tema(row) if row else None
def evict_tema_id_to_ngrams_cache():
global TEMA_ID_TO_NGRAMS_CACHE
TEMA_ID_TO_NGRAMS_CACHE = None
def get_tema_id_to_ngrams(con: Optional[Connection] = None) -> dict[int, model.NGrams]:
global TEMA_ID_TO_NGRAMS_CACHE
if TEMA_ID_TO_NGRAMS_CACHE is None:
TEMA_ID_TO_NGRAMS_CACHE = _get_tema_id_to_ngrams(con)
return TEMA_ID_TO_NGRAMS_CACHE
def _get_tema_id_to_ngrams(con: Optional[Connection] = None) -> dict[int, model.NGrams]:
query = """
SELECT id, ngrams
FROM temes
WHERE hidden = 0
"""
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query)
rows = cur.fetchall()
return {id_: cell_to_ngrams(ng) for id_, ng in rows}

View File

@@ -0,0 +1,44 @@
from typing import Optional
from folkugat_web.dal.sql import Connection, get_connection
from folkugat_web.model import temes as model
from ._conversion import row_to_tema, tema_to_row
from .query import evict_tema_id_to_ngrams_cache
def insert_tema(tema: model.Tema, con: Optional[Connection] = None) -> model.Tema:
query = """
INSERT INTO temes
(id, title, properties, links, lyrics, alternatives, ngrams,
creation_date, modification_date, hidden)
VALUES
(:id, :title, :properties, :links, :lyrics, :alternatives, :ngrams,
:creation_date, :modification_date, :hidden)
RETURNING *
"""
data = tema_to_row(tema)
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query, data)
row = cur.fetchone()
evict_tema_id_to_ngrams_cache()
return row_to_tema(row)
def update_tema(tema: model.Tema, con: Optional[Connection] = None):
query = """
UPDATE temes
SET
title = :title, properties = :properties, links = :links, lyrics = :lyrics,
alternatives = :alternatives, ngrams = :ngrams, creation_date = :creation_date,
modification_date = :modification_date, hidden = :hidden
WHERE
id = :id
"""
data = tema_to_row(tema)
with get_connection(con) as con:
cur = con.cursor()
cur.execute(query, data)
evict_tema_id_to_ngrams_cache()
return

105
folkugat_web/data.py Normal file
View File

@@ -0,0 +1,105 @@
from folkugat_web.model import temes as model
TEMES = [
# ---
model.Tema(
title="El Joan Petit",
hidden=False,
).with_ngrams(),
# ---
model.Tema(
title="Pasdoble de Muntanya (Cançó amb el nom molt llarg)",
links=[
model.Link(
type=model.LinkType.AUDIO,
subtype=model.LinkSubtype.SPOTIFY,
url="https://open.spotify.com/track/4j9Krf19c5USmMvVUCoeWa?si=3023d1d83f814886",
),
],
hidden=False,
).with_ngrams(),
# ---
model.Tema(
title="Astrid Waltz",
alternatives=["vals"],
links=[
model.Link(
type=model.LinkType.OTHER,
subtype=None,
url="https://marc.sastre.cat/folkugat",
)
],
hidden=False,
).with_ngrams(),
# ---
model.Tema(
title="Ook Pik Waltz",
alternatives=["vals"],
hidden=False,
).with_ngrams(),
# ---
model.Tema(
title="Pasdoble Patumaire",
hidden=False,
).with_ngrams(),
# ---
model.Tema(
title="El Gitano",
links=[
model.Link(
type=model.LinkType.SCORE,
subtype=model.LinkSubtype.PDF,
url="/db/temes/1/tema.pdf",
)
],
hidden=False,
).with_ngrams(),
# ---
model.Tema(
title="Pasdoble de Gitanes",
alternatives=["entrada"],
hidden=False,
).with_ngrams(),
# ---
model.Tema(
title="Jota Comunera",
hidden=False,
).with_ngrams(),
# ---
model.Tema(
title="Malaguenya de Barxeta",
lyrics=[
model.Lyrics(
title="Malaguenya de Barxeta",
content="""
Mira si he corregut terres
que he estat en Alfarrasí,
en Adzaneta i Albaida,
en el Palomar i ací.
Omplim el sarró de pa,
si vols que et guarde les cabres,
que les figues ja s'acaben
i raïm ja no hi ha.
L'altre dia jo somiava
que ja era realitat
un món sense violència
ple de pau i llibertat.
Vinc del cor de la Costera,
el poble dels socarrats,
d'allà on renaix de les cendres
el meu País Valencià.
""".strip(),
),
],
properties=[
model.Property(
model.PropertyField.AUTOR,
"Pep Jimeno 'Botifarra'"
)
],
hidden=False,
).with_ngrams(),
]

View File

View File

@@ -0,0 +1,45 @@
from fastapi import Request
from folkugat_web.model import sessions as model
from folkugat_web.services import sessions as service
from folkugat_web.templates import templates
def sessio_en_directe(request: Request):
session = service.get_live_session()
return templates.TemplateResponse(
"fragments/marquee.html",
{
"request": request,
"session": session,
}
)
def start_live_session(request: Request, session_id: int):
service.set_live_session(session_id=session_id)
session = model.Session(id=session_id)
return templates.TemplateResponse(
"fragments/sessions/live/stop.html",
{
"request": request,
"session": session,
},
headers={
"HX-Trigger": "reload-marquee"
}
)
def stop_live_session(request: Request, session_id: int):
service.stop_live_sessions()
session = model.Session(id=session_id)
return templates.TemplateResponse(
"fragments/sessions/live/start.html",
{
"request": request,
"session": session,
},
headers={
"HX-Trigger": "reload-marquee"
}
)

View File

@@ -0,0 +1,35 @@
from folkugat_web.config import nota as config
from folkugat_web.templates import templates
def input(request, value=None):
return templates.TemplateResponse(
"fragments/nota/input.html",
{
"request": request,
"value": value,
}
)
def footer(request, value, logged_in):
response = templates.TemplateResponse(
"fragments/nota/footer.html",
{
"request": request,
"value": config.LOGGED_IN_FOOTER if logged_in else value,
"logged_in": logged_in,
}
)
return response
def nota(request):
response = templates.TemplateResponse(
"fragments/nota/nota.html",
{
"request": request,
}
)
response.headers["HX-Refresh"] = "true"
return response

View File

@@ -0,0 +1,144 @@
from typing import Optional
from fastapi import Request
from fastapi.responses import HTMLResponse
from folkugat_web.config import calendari as config
from folkugat_web.model import sessions as model
from folkugat_web.model.pagines import Pages
from folkugat_web.services import sessions as service
from folkugat_web.templates import templates
def sessions_pagina(request, logged_in):
return templates.TemplateResponse(
"fragments/sessions/pagina.html",
{
"request": request,
"logged_in": logged_in,
"Pages": Pages,
"menu_selected_id": Pages.Sessions,
"calendar_list_id": config.CALENDAR_LIST_ID,
"history_list_id": config.HISTORY_LIST_ID,
}
)
def sessions_calendari(request: Request, limit: int, logged_in: bool):
sessions = service.get_next_sessions(limit=limit+1)
has_more_sessions = len(sessions) > limit
sessions = sessions[:limit]
next_session_id = sessions[0].id if sessions else None
return _sessions_list(
request=request,
sessions=sessions,
has_more_sessions=has_more_sessions,
paging_config=config.CALENDARI_PAGING_CONFIG,
list_id=config.CALENDAR_LIST_ID,
next_session_id=next_session_id,
get_sessions_url="/api/sessions/upcoming",
limit=limit,
logged_in=logged_in
)
def sessions_historial(request: Request, limit: int, logged_in: bool):
sessions = service.get_sessions_history(limit=limit+1)
has_more_sessions = len(sessions) > limit
sessions = sessions[:limit]
return _sessions_list(
request=request,
sessions=sessions,
has_more_sessions=has_more_sessions,
paging_config=config.HISTORY_PAGING_CONFIG,
list_id=config.HISTORY_LIST_ID,
get_sessions_url="/api/sessions/history",
limit=limit,
logged_in=logged_in
)
def _sessions_list(request: Request, sessions: list[model.Session], has_more_sessions: bool,
paging_config: config.PagingConfig, list_id: str, get_sessions_url: str,
limit: int, logged_in: bool, next_session_id: Optional[int] = None):
if has_more_sessions:
more_sessions = limit + paging_config.step
else:
more_sessions = None
if len(sessions) > paging_config.initial_items:
less_sessions = max(paging_config.initial_items, len(sessions) - paging_config.step)
else:
less_sessions = None
return templates.TemplateResponse(
"fragments/sessions/session_list.html",
{
"request": request,
"logged_in": logged_in,
"get_sessions_url": get_sessions_url,
"sessions": sessions,
"session_list_id": list_id,
"next_session_id": next_session_id,
"more_sessions": more_sessions,
"less_sessions": less_sessions,
"limit": limit,
"date_names": service.get_date_names,
}
)
def sessio(request: Request, session_id: int, logged_in: bool):
session = service.get_session(session_id=session_id)
return templates.TemplateResponse(
"fragments/sessions/sessio.html",
{
"request": request,
"logged_in": logged_in,
"Pages": Pages,
"session": session,
"date_names": service.get_date_names,
}
)
def sessions_editor_row(request: Request, session_date: Optional[model.Session] = None, session_id: Optional[int] = None):
if session_date is None:
if session_id is None:
raise ValueError("Must either give session or session_id")
session_date = service.get_session(session_id=session_id)
return templates.TemplateResponse(
"fragments/sessions/session_row.html",
{
"request": request,
"session": session_date,
"date_names": service.get_date_names,
"logged_in": True,
}
)
def sessions_editor_row_editing(request, session_id: int):
session = service.get_session(session_id)
return templates.TemplateResponse(
"fragments/sessions/session_row_editing.html",
{
"request": request,
"session": session,
"date_names": service.get_date_names,
}
)
def sessions_editor_insert_row(request, session_date: Optional[model.Session] = None):
session_date = service.insert_session(session_date or service.new_session())
return sessions_editor_row(request, session_date=session_date)
def sessions_editor_post_row(request, session_date: model.Session):
service.set_session(session_date)
return sessions_editor_row(request, session_date=session_date)
def sessions_editor_delete_row(session_id: int):
service.delete_session(session_id)
return HTMLResponse()

View File

@@ -0,0 +1,73 @@
from typing import Optional
from fastapi import Request
from folkugat_web.model import temes as model
from folkugat_web.services.temes import query as temes_q
from folkugat_web.templates import templates
def title(request: Request, logged_in: bool, tema: Optional[model.Tema] = None, tema_id: Optional[int] = None):
if tema is None:
if tema_id is None:
raise ValueError("Either 'tema' or 'tema_id' must be given!")
tema = temes_q.get_tema_by_id(tema_id)
return templates.TemplateResponse(
"fragments/tema/title.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
}
)
def title_editor(request: Request, logged_in: bool, tema_id: int):
tema = temes_q.get_tema_by_id(tema_id)
return templates.TemplateResponse(
"fragments/tema/editor/title.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
}
)
def lyric(request: Request, logged_in: bool, tema_id: int, lyric_idx: int):
tema = temes_q.get_tema_by_id(tema_id)
if tema is None:
raise ValueError(f"No tune exists for tema_id: {tema_id}")
if len(tema.lyrics) < lyric_idx:
raise ValueError(f'Lyric index out of bounds')
lyric = tema.lyrics[lyric_idx]
return templates.TemplateResponse(
"fragments/tema/lyric.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
"lyric_idx": lyric_idx,
"lyric": lyric,
}
)
def lyric_editor(request: Request, logged_in: bool, tema_id: int, lyric_idx: int):
tema = temes_q.get_tema_by_id(tema_id)
if tema is None:
raise ValueError(f"No tune exists for tema_id: {tema_id}")
if len(tema.lyrics) < lyric_idx:
raise ValueError(f'Lyric index out of bounds')
lyric = tema.lyrics[lyric_idx]
return templates.TemplateResponse(
"fragments/tema/editor/lyric.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
"lyric_idx": lyric_idx,
"lyric": lyric,
}
)

View File

@@ -0,0 +1,54 @@
from fastapi import Request
from folkugat_web.model import temes as model
from folkugat_web.model.pagines import Pages
from folkugat_web.services.temes import query as temes_q
from folkugat_web.services.temes import search as temes_s
from folkugat_web.templates import templates
def temes_pagina(request: Request, logged_in: bool):
return templates.TemplateResponse(
"fragments/temes/pagina.html",
{
"request": request,
"logged_in": logged_in,
"Pages": Pages,
"menu_selected_id": Pages.Temes,
}
)
def temes_busca_result(request: Request, tema: model.Tema, logged_in: bool):
return templates.TemplateResponse(
"fragments/temes/result.html",
{
"request": request,
"logged_in": logged_in,
"tema": tema,
"LinkSubtype": model.LinkSubtype,
"LinkType": model.LinkType,
}
).body.decode('utf-8')
def temes_busca(request: Request, query: str, logged_in: bool):
temes = temes_s.busca_temes(query)
return '\n'.join(
[temes_busca_result(request, tema, logged_in)
for tema in temes]
)
def tema(request: Request, tema_id: int, logged_in: bool):
tema = temes_q.get_tema_by_id(tema_id)
return templates.TemplateResponse(
"fragments/tema/pagina.html",
{
"request": request,
"logged_in": logged_in,
"Pages": Pages,
"LinkSubtype": model.LinkSubtype,
"LinkType": model.LinkType,
"tema": tema,
}
)

6
folkugat_web/log.py Normal file
View File

@@ -0,0 +1,6 @@
import logging
logging.basicConfig(level=logging.NOTSET)
logger = logging.getLogger('folkugat-web')
logger.setLevel(logging.INFO)

57
folkugat_web/main.py Normal file
View File

@@ -0,0 +1,57 @@
import subprocess
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from folkugat_web import log
from folkugat_web.config import directories, db
from folkugat_web.dal.sql.ddl import create_db
from folkugat_web.api import router
@asynccontextmanager
async def lifespan(_: FastAPI):
"""
Context manager for FastAPI app. It will run all code before `yield`
on app startup, and will run code after `yeld` on app shutdown.
"""
log.logger.info("Running Tailwind CSS...")
try:
subprocess.run([
"tailwindcss",
"-i",
directories.STATIC_DIR / "src" / "tw.css",
"-o",
directories.STATIC_DIR / "css" / "main.css",
# "--minify"
])
except Exception as e:
log.logger.error(f"Error running tailwindcss: {e}")
log.logger.info("Tailwind CSS done")
log.logger.info("Creating DB...")
create_db()
log.logger.info("DB created")
yield
def get_app() -> FastAPI:
app = FastAPI(lifespan=lifespan)
app.include_router(router)
app.mount("/static", StaticFiles(directory=directories.STATIC_DIR), name="static")
app.mount("/db/temes", StaticFiles(directory=db.DB_TEMES_DIR), name="temes")
return app
app = get_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)

Some files were not shown because too many files have changed in this diff Show More