Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
FROM node:23-bookworm

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

RUN apt-get update && apt-get install -y sqlite3 vim

# dev tools
RUN apt-get update && apt-get install -y \
sqlite3\
vim \
less
RUN npm install -g rust-just
# jj
RUN curl -Lo jj.tar.gz https://github.com/jj-vcs/jj/releases/download/v0.37.0/jj-v0.37.0-x86_64-unknown-linux-musl.tar.gz && \
tar -xzf jj.tar.gz -C /bin ./jj && \
rm jj.tar.gz && \
chmod +x /bin/jj
ENV EDITOR=vim

# project tools
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

RUN curl -L -o /bin/tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/download/v4.1.18/tailwindcss-linux-x64 && \
chmod +x /bin/tailwindcss
Expand Down
3 changes: 2 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"bradlc.vscode-tailwindcss",
"samuelcolvin.jinjahtml",
"yzhang.markdown-all-in-one",
"detachhead.basedpyright"
"detachhead.basedpyright",
"esbenp.prettier-vscode"
]
}
}
Expand Down
171 changes: 171 additions & 0 deletions app/components/base_layout.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
from htpy import (
Node,
a,
body,
button,
div,
head,
header,
html,
link,
meta,
nav,
script,
span,
title,
with_children,
)
from markupsafe import Markup

from app.components.icons.door_exit import door_exit_icon
from app.components.icons.gift import gift_icon
from app.components.icons.gift_solid import gift_solid_icon
from app.components.icons.moon import moon_icon
from app.components.icons.search import search_icon
from app.components.icons.settings import settings_icon
from app.components.icons.sun import sun_icon
from app.components.toast_script import toast_script
from app.internal.env_settings import Settings

with open("app/components/scripts/theme.js", "r") as f:
theme_script = f.read()


@with_children
def base_layout(
children: Node,
*,
user_can_logout: bool,
hide_navbar: bool = False,
meta_title: str = "AudioBookRequest",
meta_description: str = "AudioBookRequest - Your platform for managing audiobook requests.",
):
base_url = Settings().app.base_url
version = Settings().app.version

if hide_navbar:
navbar = None
else:
logout_button = (
button(
".button.btn-ghost.btn-square",
hx_post=f"{base_url}/auth/logout",
title="Logout",
)[door_exit_icon()]
if user_can_logout
else None
)

navbar = header(".shadow-lg")[
nav(".navbar")[
div(".flex-1")[
a(
".btn.btn-ghost.text-lg.hidden.sm:inline-flex",
preload=True,
href=f"{base_url}/",
)["AudioBookRequest"],
a(
".btn.btn-ghost.text-lg.sm:hidden",
preload=True,
href=f"{base_url}/",
)["ABR"],
a(
".btn.btn-ghost.btn-square",
preload=True,
href=f"{base_url}/search",
title="Search",
)[search_icon()],
a(
".btn.btn-ghost.btn-square.group.relative",
preload=True,
href=f"{base_url}/wishlist",
title="Wishlist",
)[
span(
".opacity-0.group-hover:opacity-100.absolute.left-2.top-2.transition-opacity.duration-500"
)[gift_solid_icon()],
span(
".opacity-100.group-hover:opacity-0.absolute.left-2.top-2.transition-opacity.duration-500"
)[gift_icon()],
],
],
# right-aligned buttons
div(".flex-none.flex.pr-4")[
button(
".btn.btn-ghost.btn-square.light-dark-toggle",
onclick="toggleTheme()",
)[
span(".theme-dark.svg-dark")[moon_icon()],
span(".theme-light.svg-light")[sun_icon()],
],
logout_button,
a(
".btn.btn-ghost.btn-square.group",
preload=True,
href=f"{base_url}/settings/account",
title="Settings",
)[
span(
".group-hover:rotate-90.transition-all.duration-500.ease-in-out"
)[settings_icon()]
],
],
]
]

return html(lang="en", data_theme="garden")[
head[
# meta
meta(charset="UTF-8"),
title[meta_title],
meta(
name="description",
content=meta_description,
),
meta(
name="keywords",
content="audiobooks, requests, wishlist, search, settings",
),
meta(name="viewport", content="width=device-width, initial-scale=1"),
# css/js
link(
rel="stylesheet",
href=f"{base_url}/static/globals.css?v={version}",
),
script(src=f"{base_url}/static/htmx.js?v={version}"),
script(defer=True, src=f"{base_url}/static/htmx-preload.js?v={version}"),
script[Markup(theme_script)],
toast_script(base_url=base_url, version=version),
# favicons
link(
rel="apple-touch-icon",
sizes="180x180",
href=f"{base_url}/static/apple-touch-icon.png?v={version}",
),
link(
rel="icon",
sizes="any",
type="image/svg+xml",
href=f"{base_url}/static/favicon.svg?v={version}",
),
link(
rel="icon",
type="image/png",
sizes="32x32",
href=f"{base_url}/static/favicon-32x32.png?v={version}",
),
link(
rel="icon",
type="image/png",
sizes="16x16",
href=f"{base_url}/static/favicon-16x16.png?v={version}",
),
link(
rel="manifest",
href=f"{base_url}/static/site.webmanifest?v={version}",
),
],
body(".w-screen.min-h-screen.overflow-x-hidden", hx_ext="preload")[
navbar, children
],
]
86 changes: 86 additions & 0 deletions app/components/book_card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
from htpy import a, button, div, img, span

from app.components.icons.checkmark import checkmark_icon
from app.components.icons.download import download_icon
from app.components.icons.plus import plus_icon
from app.internal.auth.authentication import DetailedUser
from app.internal.env_settings import Settings
from app.internal.models import Audiobook
from app.components.icons.photo_off import photo_off_icon


def book_card(
*,
book: Audiobook,
already_requested: bool = False,
auto_download_enabled: bool = False,
user: DetailedUser | None = None,
):
base_url = Settings().app.base_url

if book.downloaded or already_requested:
icon = checkmark_icon()
color = ".btn-ghost.bg-success.text-neutral/20"
elif auto_download_enabled and user and user.can_download():
icon = download_icon()
color = ".btn-info"
else:
icon = plus_icon()
color = ".btn-info"

return div(".flex.flex-col.book-card")[
div(
".relative.w-32.h-32.sm:w-40.sm:h-40.rounded-md.overflow-hidden.shadow.shadow-black.items-center.justify-center.flex"
)[
img(
".object-cover.w-full.h-full.hover:scale-110.transition-transform.duration-500.ease-in-out",
height="128",
width="128",
src=book.cover_image,
alt=book.title,
)
if book.cover_image
else photo_off_icon(),
# request button
button(
f"{color}.absolute.top-0.right-0.rounded-none.rounded-bl-md.btn-sm.btn.btn-square.items-center.justify-center.flex",
hx_post=f"{base_url}/search/hx/request/{book.asin}",
hx_swap="outerHTML",
hx_target="closest .book-card",
disabled=book.downloaded or already_requested,
)[icon],
],
# book info
a(
".text-sm.text-primary.font-bold.pt-1.line-clamp-2",
href=f"https://audible.com/pd/{book.asin}?ipRedirectOverride=true",
title=book.title,
target="_blank",
)[book.title],
div(".opacity-60.font-semibold.text-xs.line-clamp-1", title=book.subtitle)[
book.subtitle
],
# authors
div(
".text-xs.font-semibold.line-clamp-1",
title=", ".join(book.authors),
)[
(
a(
".hover:underline",
href=f"{base_url}/search?q={author}",
title=f"Search for books by {author}",
)[
f"{author}{',' if i < len(book.authors) - 1 else ''}",
(
span(".opacity-60")[f"{len(book.authors)} more"]
if len(book.authors) > 2
else None
),
]
for i, author in enumerate(book.authors[:2])
)
],
# runtime
div(".text-xs.opacity-60.mt-1")[f"{book.runtime_length_hrs}h"],
]
104 changes: 104 additions & 0 deletions app/components/book_card_tdom.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from dataclasses import dataclass
from tdom import Node, html

from app.components.icons.checkmark import checkmark_icon
from app.components.icons.download import download_icon
from app.components.icons.plus import plus_icon
from app.internal.auth.authentication import DetailedUser
from app.internal.env_settings import Settings
from app.internal.models import Audiobook
from app.components.icons.photo_off import photo_off_icon


@dataclass
class BookCard:
book: Audiobook
already_requested: bool = False
auto_download_enabled: bool = False
user: DetailedUser | None = None

def __call__(self) -> Node:
base_url = Settings().app.base_url

request_btn_classes = [
"absolute",
"top-0",
"right-0",
"rounded-none",
"rounded-bl-md",
"btn-sm",
"btn",
"btn-square",
"items-center",
"justify-center",
"flex",
]
if self.book.downloaded or self.already_requested:
icon = checkmark_icon()
request_btn_classes += ["btn-ghost", "bg-success", "text-neutral/20"]
elif self.auto_download_enabled and self.user and self.user.can_download():
icon = download_icon()
request_btn_classes += ["btn-primary"]
else:
icon = plus_icon()
request_btn_classes += ["btn-info"]

cover_image = (
t'<img class="object-cover w-full h-full hover:scale-110 transition-transform duration-500 ease-in-out" height="128" width="128" src="{self.book.cover_image}" alt="{self.book.title}" />'
if self.book.cover_image
else photo_off_icon()
)

authors = [
html(t"""
<a href="{base_url}/search?q={author}" title="Search for {author}" class="hover:underline">
{author}{"," if i < len(self.book.authors) - 1 else ""})
</a>""")
for i, author in enumerate(self.book.authors[:2])
]
if len(self.book.authors) > 2:
authors.append(
html(
t'<span class="opacity-60">+{len(self.book.authors) - 2} more</span>'
)
)

return html(t"""
<div class="book-card flex flex-col">
<div
class="relative w-32 h-32 sm:w-40 sm:h-40 rounded-md overflow-hidden shadow shadow-black items-center justify-center flex">
{cover_image}

<!-- Request Button -->
<button
class="{request_btn_classes}"
hx-post="{base_url}/search/hx/request/{self.book.asin}"
hx-disabled-elt="this"
hx-target="closest div.book-card"
hx-swap="outerHTML"
disabled={self.book.downloaded or self.already_requested}
>
{icon:unsafe}
</button>
</div>

<!-- Book Info -->
<a class="text-sm text-primary font-bold pt-1 line-clamp-2" title="{self.book.title}" target="_blank"
href="https://audible.com/pd/{self.book.asin}?ipRedirectOverride=true"
title={self.book.title}
>
{self.book.title}
</a>

{t'<div class="opacity-60 font-semibold text-xs" title="{self.book.subtitle}">{self.book.subtitle}</div>' if self.book.subtitle else ""}

<div class="text-xs font-semibold line-clamp-1" title="Authors: {", ".join(self.book.authors)}">
{authors}
</div>

<!-- Runtime -->
<div class="text-xs opacity-60 mt-1">
{self.book.runtime_length_hrs}h
</div>
</div>
""")
Loading