Initial commit

This commit is contained in:
2023-01-28 16:42:44 +01:00
commit 46c7eb35b3
27 changed files with 14014 additions and 0 deletions

99
src/App.svelte Normal file
View File

@@ -0,0 +1,99 @@
<script lang="ts">
import type { MediaListCollection, ProgressRow, RowId } from "./lib/types";
import { term, items, filtered } from "./stores/table";
import RowHeader from "./lib/components/table/RowHeader.svelte";
import { AniList } from "./lib/services/anilist";
import { formatProgress } from "./lib/utils/formatting";
let inputUser = "";
let progressIndex = [];
function hotEncode(index, data) {
return index.map((idx) => data[idx] ?? null);
}
function processUser(userName: string, mediaList: MediaListCollection) {
progressIndex.push(userName);
for (const list of mediaList.lists) {
for (const listEntry of list.entries) {
let rowId = listEntry.media.id;
let row: ProgressRow = $items.get(rowId) ?? {
media: listEntry.media,
progress: {},
};
row.progress[userName] = listEntry.progress;
items.update((map) => map.set(rowId, row));
}
}
progressIndex = progressIndex;
}
async function handleAddUser() {
if (inputUser.length === 0) return;
const { error, data } = await AniList.fetchMediaList(inputUser);
const resp = await data.json();
processUser(inputUser, resp.data.MediaListCollection);
inputUser = "";
}
let searchTerm = "";
$: term.set(searchTerm.toLowerCase());
const styleClasses = {
tableHeader: "text-sm font-medium text-gray-900 px-6 py-4 text-left",
};
</script>
<main class="w-screen min-h-screen bg-slate-50">
<div class="container mx-auto">
<table class="min-w-full">
<thead>
<th scope="col" class="{styleClasses.tableHeader} w-1/3">
<input
class="border rounded-sm px-3 py-2"
type="text"
placeholder="Filter media"
bind:value="{searchTerm}"
/>
</th>
{#each progressIndex as userName}
<th scope="col" class="{styleClasses.tableHeader}">
{userName}
</th>
{/each}
<th scope="col" class="{styleClasses.tableHeader}">
<input
class="border rounded-sm px-3 py-2"
type="text"
placeholder="AniList user"
bind:value="{inputUser}"
/>
<button class="py-2 px-3 bg-gray-100" on:click="{handleAddUser}">
Add User
</button>
</th>
</thead>
<tbody>
{#each Array.from($filtered.values()) as { media, progress }}
<tr>
<td class="px-6 py-3 text-sm font-medium text-gray-900">
<RowHeader media="{media}" />
</td>
{#each hotEncode(progressIndex, progress) as entry}
<td class="text-sm text-gray-900 font-light px-6 py-4">
{formatProgress(entry, media.episodes)}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</main>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { type Media } from "../../types";
import { formatTimestamp } from "../../utils/formatting";
export let media: Media;
</script>
<a href="{media.siteUrl}" target="_blank" rel="noreferrer">
<div class="flex flex-row items-center space-x-2">
<img src="{media.coverImage.medium}" alt="cover" class="h-20 rounded-sm" />
<div>
<p class="text-ellipsis">
{media.title.english ?? media.title.romaji}
</p>
<p>Status: {media.status}</p>
{#if media.nextAiringEpisode}
<p>
Ep. {media.nextAiringEpisode.episode} airs: {formatTimestamp(
media.nextAiringEpisode.airingAt
)}
</p>
{/if}
</div>
</div>
</a>

View File

@@ -0,0 +1,22 @@
import { until } from "@open-draft/until";
import { mediaListQuery } from "./queries";
export class AniList {
static apiUrl = "https://graphql.anilist.co";
static buildOptions(query, variables) {
return {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ query, variables }),
};
}
static async fetchMediaList(username: string) {
const options = this.buildOptions(mediaListQuery, { username });
return await until(() => fetch(this.apiUrl, options));
}
}

View File

@@ -0,0 +1,30 @@
export const mediaListQuery = `
query me($username: String) {
MediaListCollection(userName: $username, type: ANIME, status:CURRENT) {
lists {
entries {
progress
media {
id
status(version:2)
coverImage {
medium
color
}
genres
siteUrl
title {
romaji
english
}
nextAiringEpisode {
airingAt
episode
}
episodes
}
}
}
}
}
`;

4665
src/lib/types/anilist.ts Normal file

File diff suppressed because it is too large Load Diff

8
src/lib/types/index.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { Media } from "./anilist";
export * from "./anilist";
export type RowId = number;
export type ProgressEntry = Record<string, number>;
export type ProgressRow = { media: Media; progress: ProgressEntry };
export type ProgressTable = Map<RowId, ProgressRow>;

View File

@@ -0,0 +1,23 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
dayjs.extend(utc);
export function formatTimestamp(unix: number): string {
return dayjs(unix * 1000)
.local()
.format("MM-DDTHH");
}
export function formatProgress(
current: number | null,
total: number | null
): string {
if (Number.isInteger(current) === false) {
return "";
}
let res = current.toString();
if (Number.isInteger(total)) res += `/${total}`;
return res;
}

8
src/main.ts Normal file
View File

@@ -0,0 +1,8 @@
import "./styles/index.css";
import App from "./App.svelte";
const app = new App({
target: document.getElementById("app"),
});
export default app;

20
src/stores/table.ts Normal file
View File

@@ -0,0 +1,20 @@
import { writable, derived } from "svelte/store";
import type { ProgressRow, ProgressTable, RowId } from "../lib/types";
export const term = writable("");
export const items = writable<ProgressTable>(new Map());
function search([term, items]): ProgressTable {
return new Map(
[...items].filter(([_, value]: [RowId, ProgressRow]) => {
let title = value.media.title;
return (
title.english?.toLowerCase().includes(term) ||
title.romaji?.toLowerCase().includes(term)
);
})
);
}
export const filtered = derived([term, items], search);

3
src/styles/index.css Normal file
View File

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

2
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />