serier och taggar
This commit is contained in:
@@ -3,6 +3,7 @@ import type { Component } from "solid-js";
|
||||
import Home from "./components/Home";
|
||||
import BookList from "./components/BookList";
|
||||
import AuthorList from "./components/AuthorList";
|
||||
import SeriesList from "./components/SeriesList";
|
||||
|
||||
const App: Component = () => {
|
||||
return (
|
||||
@@ -35,6 +36,9 @@ const App: Component = () => {
|
||||
<a class="nav-link" href="/authors">
|
||||
Authors
|
||||
</a>
|
||||
<a class="nav-link" href="/series">
|
||||
Series
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,6 +50,7 @@ const App: Component = () => {
|
||||
<Route path="/books/author/:authorid" component={BookList} />
|
||||
<Route path="/books" component={BookList} />
|
||||
<Route path="/authors" component={AuthorList} />
|
||||
<Route path="/series" component={SeriesList} />
|
||||
<Route path="/" component={Home} />
|
||||
</Router>
|
||||
</main>
|
||||
|
89
Frontend/src/components/SeriesList.tsx
Normal file
89
Frontend/src/components/SeriesList.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createSignal, onMount, For, type Component } from "solid-js";
|
||||
import AuthorCard from "./AuthorCard";
|
||||
import BibblanService from "../services/bibblanservice";
|
||||
import { series, listBook } from "../types/types";
|
||||
|
||||
const SeriesList: Component = () => {
|
||||
const [series, setSeries] = createSignal<series[]>([]);
|
||||
const [query, setQuery] = createSignal("");
|
||||
|
||||
const update = (query: string) => {
|
||||
setQuery(query);
|
||||
BibblanService.getSeries(query).then(setSeries);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
update(query());
|
||||
});
|
||||
|
||||
const distinctAuthors = (
|
||||
books: listBook[]
|
||||
): { id: number; name: string }[] => {
|
||||
return [
|
||||
...new Set(
|
||||
books.map((b) => JSON.stringify({ id: b.authorId, name: b.authorName }))
|
||||
),
|
||||
].map((x) => JSON.parse(x)) as { id: number; name: string }[];
|
||||
};
|
||||
|
||||
const minDate = (books: listBook[]): string => {
|
||||
if (books.length === 0) return "";
|
||||
const darr = books.map((b) => new Date(b.pubDate));
|
||||
const d = darr.reduce((min, b) => (b < min ? b : min), darr[0]);
|
||||
return d?.toISOString().substr(0, 10) ?? "";
|
||||
};
|
||||
|
||||
const maxDate = (books: listBook[]): string => {
|
||||
if (books.length === 0) return "";
|
||||
const darr = books.map((b) => new Date(b.pubDate));
|
||||
const d = darr.reduce((max, b) => (b > max ? b : max), darr[0]);
|
||||
return d?.toISOString().substr(0, 10) ?? "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Series!</h1>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control mb-3"
|
||||
placeholder="Search..."
|
||||
onInput={(e) => update(e.currentTarget.value)}
|
||||
/>
|
||||
<table class="table p-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Author</th>
|
||||
<th>Book Count</th>
|
||||
<th>Published</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={series()}>
|
||||
{(item) => (
|
||||
<tr>
|
||||
<td>{item.id}</td>
|
||||
<td>{item.name}</td>
|
||||
<td>
|
||||
<For each={distinctAuthors(item.books)}>
|
||||
{(author) => (
|
||||
<a href={`/books/author/${author.id}`}>{author.name}</a>
|
||||
)}
|
||||
</For>
|
||||
</td>
|
||||
<td>{item.books.length}</td>
|
||||
<td>
|
||||
{minDate(item.books) ?? "N/A"} -{" "}
|
||||
{maxDate(item.books) ?? "N/A"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SeriesList;
|
@@ -25,6 +25,17 @@ const BibblanService = {
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
},
|
||||
|
||||
getSeries: async (
|
||||
query: string | undefined = undefined
|
||||
): Promise<author[]> => {
|
||||
let url = "/api/bibblan/series";
|
||||
if (query != undefined && query.length > 0) {
|
||||
url += `?query=${encodeURIComponent(query)}`;
|
||||
}
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
|
||||
export default BibblanService;
|
||||
|
@@ -18,4 +18,19 @@ interface author {
|
||||
bookCount: number;
|
||||
}
|
||||
|
||||
export type { book, author };
|
||||
interface listBook {
|
||||
id: number;
|
||||
title: string;
|
||||
authorId: number;
|
||||
authorName: string;
|
||||
pubDate: Date;
|
||||
seriesIndex: number;
|
||||
}
|
||||
|
||||
interface series {
|
||||
id: number;
|
||||
name: string;
|
||||
books: Array<listBook>;
|
||||
}
|
||||
|
||||
export type { book, author, series, listBook };
|
||||
|
@@ -51,5 +51,59 @@ namespace Bibblan.Business.Services
|
||||
return conn.Query<AuthorVm>(query).Take(count).ToList();
|
||||
|
||||
}
|
||||
|
||||
internal List<SeriesVm> GetSeries(int count, BookFilter? filter)
|
||||
{
|
||||
var query = @"select s.id as seriesid, s.name as seriesname, b.*, a.*
|
||||
--b.id as bookid, b.title, b.pubdate, b.series_index,
|
||||
--a.id as authorid, a.name as authorname
|
||||
from series s
|
||||
inner join books_series_link bsl on s.id = bsl.series
|
||||
inner join books b on bsl.book = b.id
|
||||
inner join books_authors_link bal on b.id = bal.book
|
||||
inner join authors a on bal.author = a.id";
|
||||
if (!String.IsNullOrWhiteSpace(filter?.Query))
|
||||
{
|
||||
filter.Query = filter.Query?.ToLowerInvariant().Trim() ?? "";
|
||||
query += $" where lower(s.name) like '%{filter.Query}%'";
|
||||
}
|
||||
var conn = new NpgsqlConnection(settings.BibblanConnection);
|
||||
var lookup = new Dictionary<long, SeriesVm>();
|
||||
conn.Query<SeriesVm, Books, Authors, SeriesVm>(query, (s,b,a) =>
|
||||
{
|
||||
SeriesVm svm;
|
||||
if (!lookup.TryGetValue(s.Id, out svm))
|
||||
{
|
||||
lookup.Add(s.Id, svm = s);
|
||||
}
|
||||
svm.Books.Add(new ListBook
|
||||
{
|
||||
AuthorId = a.Id,
|
||||
AuthorName = a.Name,
|
||||
Id = b.Id,
|
||||
PubDate = b.Pubdate,
|
||||
SeriesIndex = b.SeriesIndex,
|
||||
Title = b.Title
|
||||
|
||||
});
|
||||
return svm;
|
||||
}, splitOn: "id").Take(count).ToList();
|
||||
return lookup.Values.AsList();
|
||||
}
|
||||
|
||||
internal List<Tag> GetTags(BookFilter? filter)
|
||||
{
|
||||
var query = @"select t.id, t.name, count(btl.*) as bookcount from tags t
|
||||
inner join books_tags_link btl on t.id = btl.tag
|
||||
group by t.id, t.name
|
||||
order by bookcount desc";
|
||||
if (!String.IsNullOrWhiteSpace(filter?.Query))
|
||||
{
|
||||
filter.Query = filter.Query.ToLowerInvariant();
|
||||
query += $" having lower(name) like '%{filter.Query}%'";
|
||||
}
|
||||
var conn = new NpgsqlConnection(settings.BibblanConnection);
|
||||
return conn.Query<Tag>(query).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -66,5 +66,28 @@ namespace Bibblan.Controllers
|
||||
}
|
||||
|
||||
|
||||
[HttpGet("series")]
|
||||
public IActionResult GetSeries(string query = null)
|
||||
{
|
||||
BookFilter filter = query != null ? new BookFilter
|
||||
{
|
||||
Query = query
|
||||
} : null;
|
||||
|
||||
var series = _db.GetSeries(100, filter).ToList();
|
||||
return Ok(series);
|
||||
}
|
||||
|
||||
[HttpGet("tags")]
|
||||
public IActionResult GetTags(string query = null)
|
||||
{
|
||||
BookFilter filter = query != null ? new BookFilter
|
||||
{
|
||||
Query = query
|
||||
} : null;
|
||||
|
||||
var tags = _db.GetTags(filter).ToList();
|
||||
return Ok(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,10 @@
|
||||
using Bibblan.Business.Services;
|
||||
using Bibblan.Models;
|
||||
using Bibblan.ViewModels;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Configuration.AddJsonFile($"appsettings.user.json", true, true);
|
||||
@@ -28,6 +31,15 @@ builder.Services.AddDbContext<PostgresCalibreContext>(options => options.UseNpgs
|
||||
var app = builder.Build();
|
||||
|
||||
Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true;
|
||||
Dapper.SqlMapper.SetTypeMap(
|
||||
typeof(SeriesVm),
|
||||
new CustomPropertyTypeMap(
|
||||
typeof(SeriesVm),
|
||||
(type, columnName) =>
|
||||
type.GetProperties().FirstOrDefault(prop =>
|
||||
prop.GetCustomAttributes(false)
|
||||
.OfType<ColumnAttribute>()
|
||||
.Any(attr => attr.Name == columnName))));
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.MapStaticAssets();
|
||||
|
32
Server/ViewModels/SeriesVm.cs
Normal file
32
Server/ViewModels/SeriesVm.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Bibblan.ViewModels
|
||||
{
|
||||
public class Tag
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public int BookCount { get; set; }
|
||||
}
|
||||
|
||||
public class ListBook
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string Title { get; set; }
|
||||
public DateTime PubDate { get; set; }
|
||||
public double SeriesIndex { get; set; }
|
||||
public long AuthorId { get; set; }
|
||||
public string AuthorName { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public class SeriesVm
|
||||
{
|
||||
[Column("seriesid")]
|
||||
public long Id { get; set; }
|
||||
[Column("seriesname")]
|
||||
public string Name { get; set; }
|
||||
public List<ListBook> Books { get; set; } = new();
|
||||
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user