Compare commits

..
12 Commits
Author SHA1 Message Date
jakob b8660d3200 Add remaining files (with AI taint) 2025-11-13 20:02:25 +01:00
jakob a611362ad8 Add Song class 2025-11-13 20:01:16 +01:00
jakob 0373572eb7 Implement favorites page 2025-11-13 19:55:40 +01:00
jakob 6773d6d9bc Add pagination elements 2025-11-13 19:48:09 +01:00
jakob 7c6ba261f8 Add SongList 2025-11-13 19:47:51 +01:00
jakob 0b69162d0b Add search bar 2025-11-13 19:47:34 +01:00
jakob 740cee3d44 Add header (featuring SearchBar and TabBar) 2025-11-13 19:47:22 +01:00
jakob be55bb91dc Add index.html
With fonts retrieved from Google Fonts, which is not ideal, but we can inlne those
2025-11-13 19:47:07 +01:00
jakob b3cefa32b7 Add tab bar 2025-11-13 19:42:27 +01:00
jakob 30b9bdf37c Add artist <section> 2025-11-13 19:42:07 +01:00
jakob 008ec35176 Add page for all songs 2025-11-13 19:41:24 +01:00
jakob fda41d1bdd Add base page (header and main) 2025-11-13 19:39:38 +01:00
15 changed files with 640 additions and 0 deletions
@@ -0,0 +1,23 @@
import Base from "./Base.js"
import SongList from "../pieces/SongList.js"
import Song from "../../model/Song.js"
import search from "../../model/search.js"
export default {
oncreate() {
document.title = "Alle Songs · Karaokatalog"
},
oninit() {
Song.load()
},
view() {
const songs = search.apply(Song.all)
const message = !songs ? "Lade Songs..." : songs.length === 0 ? "Keine Songs gefunden." : null
return m(
Base,
message && m("p", message),
songs && m(SongList, { songs })
)
},
}
@@ -0,0 +1,7 @@
import Header from "../pieces/Header.js"
export default {
view(vnode) {
return [m(Header), m("main", vnode.children)]
},
}
@@ -0,0 +1,41 @@
import Base from "./Base.js"
import SongList from "../pieces/SongList.js"
import Song from "../../model/Song.js"
import search from "../../model/search.js"
import ClearSearchLink from "../pieces/ClearSearchLink.js"
export default {
oncreate() {
document.title = "Favoriten · Karaokatalog"
},
oninit() {
Song.load()
},
view() {
const allFavorites = Song.all?.filter(song => song.favorite)
const favoritesToShow = search.apply(allFavorites)
let message = null
if(!allFavorites) {
message = "Lade Songs..."
} else if(allFavorites.length === 0) {
message = "Du hast noch keine Favoriten markiert."
} else if(favoritesToShow.length === 0) {
message = [
`Deine Suche ergab keine Treffer unter deinen ${allFavorites.length} Favoriten. `,
m(ClearSearchLink, { totalFavoriteCount: allFavorites.length}),
]
} else if(allFavorites.length > favoritesToShow.length) {
message = [
`Deine Suche zeigt ${favoritesToShow.length} von deinen insgesamt ${allFavorites.length} Favoriten. `,
m(ClearSearchLink, { totalFavoriteCount: allFavorites.length}),
]
}
return m(
Base,
message && m("p", message),
favoritesToShow && m(SongList, { songs: favoritesToShow }),
)
},
}
@@ -0,0 +1,14 @@
import Song from "./Song.js"
export default {
view: function (vnode) {
const { artist, songs } = vnode.attrs
return m(
"section.artist",
[
m("h2", artist),
songs.map(song => m(Song, { song })),
],
)
},
}
@@ -0,0 +1,27 @@
/* TODO: TAINT: AI-generated. This file was AI-generated (Gemini and/or Cursor), and I haven't manually reviewed it yet.
I should do this at some point in the future and change things I don't like. */
import search from "../../model/search.js"
export default {
view: function (vnode) {
return ["Möchtest du ", m(
"a.clear-search-link",
{
onclick: e => {
e.preventDefault()
search.query = null
},
href: "#",
role: "button",
tabindex: 0,
onkeypress: e => {
if ((e.key === "Enter" || e.key === " ")) {
e.preventDefault()
search.query = null
}
},
},
`alle ${vnode.attrs.totalFavoriteCount} Favoriten anzeigen`,
), "?"]
},
}
@@ -0,0 +1,8 @@
import SearchBar from "./SearchBar.js"
import TabBar from "./TabBar.js"
export default {
view() {
return m("header", [m(SearchBar), m(TabBar)])
},
}
@@ -0,0 +1,16 @@
import PaginationControls from "./PaginationControls.js"
const ELEMENTS_PER_PAGE = 25
export default {
view(vnode) {
const elements = vnode.attrs.elements.toArray()
const totalPages = Math.ceil(elements.length / ELEMENTS_PER_PAGE)
const startIndex = (this.currentPageIndex || 0) * ELEMENTS_PER_PAGE
return [
elements.slice(startIndex, startIndex + ELEMENTS_PER_PAGE),
m(PaginationControls, { currentPage: (this.currentPageIndex || 0) + 1, totalPages, onPageChange: i => this.currentPageIndex = i - 1 })
]
}
}
@@ -0,0 +1,122 @@
/* TODO: TAINT: AI-generated. This file was AI-generated (Gemini and/or Cursor), and I haven't manually reviewed it yet.
I should do this at some point in the future and change things I don't like. */
const PaginationControls = {
view: function (vnode) {
const { currentPage, totalPages, onPageChange } = vnode.attrs
if (totalPages <= 1) {
return null // Keine Steuerelemente anzeigen, wenn nicht benötigt
}
const pages = []
const delta = 2 // Anzahl der Seiten, die um die aktuelle Seite herum angezeigt werden
// Hilfsfunktion zum Hinzufügen einer Seitenzahl
const addPage = pageNumber => {
pages.push(
m(
"span.page-number",
{
class:
pageNumber === currentPage ? "active" : "clickable",
onclick: () =>
pageNumber !== currentPage
? onPageChange(pageNumber)
: null,
role: "button",
tabindex: 0, // Für Tastaturnavigation
onkeypress: e => {
// Für Tastaturnavigation (Enter/Space)
if (
(e.key === "Enter" || e.key === " ") &&
pageNumber !== currentPage
) {
e.preventDefault()
onPageChange(pageNumber)
}
},
"aria-label": `Gehe zu Seite ${pageNumber}`,
"aria-current":
pageNumber === currentPage ? "page" : undefined,
},
pageNumber,
),
)
}
// "Zurück"-Button
const prevButton = m(
"button.pagination-button",
{
onclick: () => onPageChange(currentPage - 1),
disabled: currentPage === 1,
"aria-label": "Vorherige Seite",
},
"Zurück",
)
// "Weiter"-Button
const nextButton = m(
"button.pagination-button",
{
onclick: () => onPageChange(currentPage + 1),
disabled: currentPage === totalPages,
"aria-label": "Nächste Seite",
},
"Weiter",
)
// Logik zur Seitenzahlanzeige
if (totalPages <= 7) {
// Alle Seiten anzeigen, wenn es 7 oder weniger sind
for (let i = 1; i <= totalPages; i++) {
addPage(i)
}
} else {
addPage(1) // Immer die erste Seite anzeigen
if (currentPage > delta + 2) {
// Auslassungspunkte anzeigen, wenn die aktuelle Seite weit vom Anfang entfernt ist
pages.push(m("span.dots", "..."))
}
// Bereich der Seiten um die aktuelle Seite bestimmen
let start = Math.max(2, currentPage - delta)
let end = Math.min(totalPages - 1, currentPage + delta)
if (currentPage <= delta + 1) {
// Aktuelle Seite ist nahe am Anfang
start = 2
end = Math.min(totalPages - 1, 2 * delta + 1)
} else if (currentPage >= totalPages - delta) {
// Aktuelle Seite ist nahe am Ende
start = Math.max(2, totalPages - 2 * delta)
end = totalPages - 1
}
// Korrektur für den Fall, dass start und end sich überschneiden oder ungültig werden
if (start > end && currentPage < totalPages / 2) {
// Wenn start > end und wir sind eher am Anfang
end = start // Zeige zumindest die Startseite
} else if (start > end && currentPage > totalPages / 2) {
// Wenn start > end und wir sind eher am Ende
start = end // Zeige zumindest die Endseite
}
for (let i = start; i <= end; i++) {
if (i > 0) addPage(i) // Sicherstellen, dass i positiv ist
}
if (currentPage < totalPages - delta - 1 && end < totalPages - 1) {
// Auslassungspunkte anzeigen, wenn die aktuelle Seite weit vom Ende entfernt ist
pages.push(m("span.dots", "..."))
}
if (totalPages > 1) addPage(totalPages) // Immer die letzte Seite anzeigen (wenn nicht 1)
}
return m("div.pagination-controls", [prevButton, ...pages, nextButton])
},
}
export default PaginationControls
@@ -0,0 +1,15 @@
import search from "../../model/search.js"
export default {
view: function () {
return m("search", [
m("input[type=search]", {
value: search.query,
placeholder: "Songs oder Interpreten suchen...",
oninput: e => {
search.query = e.target.value
},
}),
])
},
}
@@ -0,0 +1,25 @@
export default {
view: function (vnode) {
const { song } = vnode.attrs
return m("article.song", [
m(".info", [
m(".title", song.title),
m(".artist", song.artist),
]),
m(".actions", [
m(
"span.material-symbols-outlined.heart-icon",
{
class: song.favorite ? "is-favorite" : "",
onclick: () => song.toggleFavorite(),
title: song.favorite
? "Als Favorit entfernen"
: "Als Favorit markieren",
},
"favorite",
),
]),
])
},
}
@@ -0,0 +1,11 @@
import Artist from "./Artist.js"
import Pagination from "./Pagination.js"
export default {
view: function (vnode) {
const sortedSongs = vnode.attrs.songs.toSorted((a, b) => a.artist.localeCompare(b.artist) || a.title.localeCompare(b.title))
const songsByArtist = Map.groupBy(sortedSongs, song => song.artist)
return m(Pagination, { elements: songsByArtist.entries().map(([artist, songs]) => m(Artist, { artist, songs })) })
},
}
@@ -0,0 +1,16 @@
/* TODO: TAINT: AI-generated. This file was AI-generated (Gemini and/or Cursor), and I haven't manually reviewed it yet.
I should do this at some point in the future and change things I don't like. */
export const Tab = {
view(vnode) {
return m(
m.route.Link,
{
class: m.route.get() === vnode.attrs.href ? "active" : "",
href: vnode.attrs.href,
role: "button", // Für Barrierefreiheit
tabindex: 0, // Für Tastaturnavigation
},
vnode.attrs.label,
)
},
}
@@ -0,0 +1,16 @@
import { Tab } from "./Tab.js"
export default {
view() {
return m("nav", [
m(Tab, {
href: "/all",
label: "Alle",
}),
m(Tab, {
href: "/favorites",
label: "Favoriten",
}),
])
},
}
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Karaokatalog</title>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main>
<p>Lade Karaokatalog-App...</p>
</main>
<script type="module" src="karaokatalog.js"></script>
</body>
</html>
+275
View File
@@ -0,0 +1,275 @@
/* TODO: TAINT: AI-generated. This file was AI-generated (Gemini and/or Cursor), and I haven't manually reviewed it yet.
I should do this at some point in the future and change things I don't like. */
/* Globale Resets und Basis-Styling (wie vorher) */
body,
html {
margin: 0;
padding: 0;
font-family: "Roboto", sans-serif;
background-color: #f4f4f9;
color: #333;
line-height: 1.6;
}
main > p {
text-align: center;
padding: 1em;
font-size: 1.1em;
color: #757575;
}
/* Suchleiste (Höhe ca. 60px) */
search {
position: fixed;
top: 0;
left: 0;
right: 0;
background-color: #3f51b5; /* Indigo - Material Primary */
padding: 12px 16px;
z-index: 1000;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
box-sizing: border-box; /* Stellt sicher, dass Padding die Höhe nicht sprengt */
height: 60px; /* Feste Höhe für genaue Positionierung */
}
input[type="search"] {
width: 100%;
padding: 10px 12px;
border-radius: 4px;
border: none;
font-size: 16px;
box-sizing: border-box;
background-color: #fff;
color: #333;
}
input[type="search"]::placeholder {
color: #888;
}
/* Tab Bar (Höhe ca. 48px) */
nav {
display: flex;
position: fixed;
top: 60px; /* Direkt unter der Suchleiste */
left: 0;
right: 0;
background-color: #3f51b5; /* Gleiche Farbe wie Suchleiste für einen einheitlichen Header-Block */
z-index: 999; /* Unter der Suchleiste, falls sie überlappen könnten */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); /* Dezenter Schatten */
height: 48px; /* Feste Höhe */
box-sizing: border-box;
}
nav a {
flex-grow: 1;
display: flex; /* Für vertikales Zentrieren des Texts */
align-items: center;
justify-content: center;
padding: 0 8px; /* Horizontal Padding */
text-align: center;
color: rgba(
255,
255,
255,
0.75
); /* Etwas helleres, aber gedämpftes Weiß für inaktive Tabs */
cursor: pointer;
font-weight: 500;
font-size: 0.9em; /* Leicht kleiner als Haupttext */
text-transform: uppercase;
border-bottom: 3px solid transparent; /* Platzhalter für den aktiven Indikator */
transition:
color 0.2s ease-in-out,
border-bottom-color 0.2s ease-in-out;
user-select: none;
text-decoration: none;
}
nav a:hover {
background-color: rgba(255, 255, 255, 0.1); /* Leichter Hover-Effekt */
}
nav a.active {
color: #ffffff; /* Reines Weiß für aktiven Tab */
border-bottom-color: #ff4081; /* Material Accent Pink für den Indikator */
}
/* song .Liste Container */
main {
padding-top: calc(60px + 48px); /* Höhe Suchleiste + Höhe TabBar */
padding-bottom: 20px; /* Platz für Paginierung, falls am Ende */
}
/* Link zum Zurücksetzen der Suche in der main > p */
main > p a.clear-search-link,
main > p .clear-search-link {
/* Zweite Regel für den Fall, dass Mithril das 'a' nicht direkt rendert */
color: #3f51b5; /* Material Indigo, passend zum Header */
text-decoration: underline;
cursor: pointer;
font-weight: 500; /* Etwas hervorheben */
background: none; /* Sicherstellen, dass keine Button-Styles übernommen werden */
border: none;
padding: 0;
display: inline; /* Damit es sich wie ein normaler Link im Text verhält */
}
main > p a.clear-search-link:hover,
main > p a.clear-search-link:focus,
main > p .clear-search-link:hover,
main > p .clear-search-link:focus {
color: #303f9f; /* Dunkleres Indigo für Hover/Focus */
text-decoration: none; /* Optional: Unterstreichung bei Hover entfernen */
}
/* Restliche Styles für Artist-Group, song .Item etc. bleiben wie zuvor */
section.artist {
margin: 12px 8px;
background-color: #ffffff;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
section.artist > h2 {
margin: 0;
padding: 12px 16px;
background-color: #e8eaf6;
color: #303f9f;
font-size: 1.1em;
font-weight: 500;
border-bottom: 1px solid #c5cae9;
}
.song {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s ease-in-out;
}
.song:last-child {
border-bottom: none;
}
.song:hover {
background-color: #f9f9f9;
}
.song .info {
flex-grow: 1;
margin-right: 8px; /* Abstand zum Icon */
overflow: hidden; /* Verhindert Textüberlauf */
}
.song .title {
font-size: 1em;
font-weight: 400;
color: #212121;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis; /* Text abschneiden bei Überlauf */
}
.song .artist {
font-size: 0.85em;
color: #757575;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song .actions {
margin-left: auto;
flex-shrink: 0;
}
.heart-icon {
cursor: pointer;
color: #bdbdbd;
font-size: 24px;
user-select: none;
transition:
color 0.2s ease-in-out,
transform 0.1s ease-out;
vertical-align: middle;
}
.heart-icon:hover {
transform: scale(1.1);
}
.heart-icon.is-favorite {
color: #ff4081;
font-variation-settings: "FILL" 1;
}
.material-symbols-outlined {
font-variation-settings:
"FILL" 0,
"wght" 400,
"GRAD" 0,
"opsz" 24;
}
/* NEU: Pagination Controls Styles */
.pagination-controls {
display: flex;
justify-content: center;
align-items: center;
padding: 20px 0; /* Mehr Padding für bessere Sichtbarkeit */
margin-top: 10px;
user-select: none;
flex-wrap: wrap; /* Erlaubt Umbruch bei vielen Seitenzahlen auf kleinen Bildschirmen */
}
.pagination-controls button.pagination-button, /* Klasse für Buttons hinzugefügt */
.pagination-controls span.page-number {
margin: 4px; /* Etwas mehr Margin für Umbruch */
padding: 8px 12px;
border: 1px solid #ddd;
background-color: #fff;
color: #3f51b5; /* Material Indigo */
cursor: pointer;
border-radius: 4px;
transition:
background-color 0.2s,
color 0.2s,
border-color 0.2s;
font-size: 0.9em;
min-width: 36px; /* Mindestbreite für bessere Klickbarkeit */
text-align: center;
box-sizing: border-box;
}
.pagination-controls button.pagination-button:hover:not(:disabled),
.pagination-controls span.page-number.clickable:hover {
background-color: #e8eaf6; /* Heller Indigo-Ton für Hover */
border-color: #c5cae9;
}
.pagination-controls button.pagination-button:disabled {
color: #aaa;
cursor: not-allowed;
background-color: #f9f9f9;
border-color: #eee;
}
.pagination-controls span.page-number.active {
background-color: #3f51b5;
color: #fff;
border-color: #3f51b5;
font-weight: bold;
cursor: default;
}
.pagination-controls span.dots {
padding: 8px 6px;
color: #777;
margin: 4px;
font-size: 0.9em;
}