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
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@
{/if}
{:else if result.media_type === 'media_series'}
<Icon data={icons.Copy} fill={icon_color} stroke="none" size={icon_size} />
<span>({result.media_reference.media_series_length})</span>
<a href="/series/{result.media_reference.id}" class="hover:text-green-500 hover:underline">
<span>({result.media_reference.media_series_length})</span>
</a>
{:else if result.media_type === 'grouped'}
<Icon data={icons.Copy} fill={icon_color} stroke="none" size={icon_size} />
<SearchLink
Expand Down
34 changes: 34 additions & 0 deletions packages/web/src/routes/series/[series_id]/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import MediaDetails from './components/MediaDetails.svelte'
import SearchParams from './components/SearchParams.svelte'
import MediaList from './components/MediaList.svelte'
import MediaView from './components/MediaView.svelte'
import Footer from './components/Footer.svelte'
import Header from './components/Header.svelte'

import { SeriesController } from './controller.ts'

/** @type {import('./$types').PageProps} */
let props = $props()

const controller = new SeriesController(props.data.config, props.data.series_id)
let { dimensions, focus, queryparams } = controller.runes
focus.stack({component: 'SeriesPage', focus: 'page'})
</script>

<div class="h-dvh">
<Header {controller} bind:height={dimensions.heights.header} />
<div class="grid grid-cols-[auto_1fr]">
<MediaDetails {controller} />
<div class="relative">
<MediaView {controller} />
<MediaList {controller} />
</div>
</div>
<Footer {controller} bind:height={dimensions.heights.footer} />
</div>

<svelte:window
on:keydown={controller.keybinds.handler}
bind:innerHeight={dimensions.heights.screen}
/>
5 changes: 5 additions & 0 deletions packages/web/src/routes/series/[series_id]/+page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export function load({ params }) {
return {
series_id: parseInt(params.series_id)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<script lang="ts">
import * as theme from '$lib/theme.ts'
import type { SeriesController } from '../controller.ts'

let {controller, height = $bindable()}: {controller: SeriesController, height: number} = $props()
const runes = controller.runes


function human_readable_number(n: number) {
const number_string = n.toString()
const number_groups: string[] = []
let current_group: string = ''
for (const digit of Array.from(number_string).reverse()) {
current_group += digit
if (current_group.length >= 3) {
number_groups.push(current_group)
current_group = ''
}
}
if (current_group) {
number_groups.push(current_group)
}
return Array.from(number_groups.join(',')).reverse().join('')
}
</script>

<style>
.slider-handle::-webkit-slider-thumb {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--slider-thumb-color);
}
.slider-handle::-moz-range-thumb {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: var(--slider-thumb-color);
}
.slider-handle::-moz-range-thumb:hover {
background: var(--slider-thumb-color-hover);
}
</style>

<footer
class="
bg-gray-700 items-center px-2 py-1
text-slate-900 border-t border-t-[2px] border-gray-800"
bind:clientHeight={height}
>

<div class="grid grid-cols-[1fr_auto_auto] gap-4 justify-between">
<div class="w-full grid grid-cols-[auto_1fr_auto] gap-4 items-center" style="--slider-thumb-color: {theme.colors.green[300]}; --slider-thumb-color-hover: {theme.colors.green[500]}">
<label for="thumbnail-size">Thumbnail Size</label>
<input
class="appearance-none h-[7px] rounded-full bg-gray-800 slider-handle"
name="thumbnail-size"
type="range"
min={50}
max={500}
value={runes.settings.ui.media_list.thumbnail_size}
oninput={e => {
runes.settings.set('ui.media_list.thumbnail_size', e.target.value)
}}>
<span>{runes.settings.ui.media_list.thumbnail_size}px</span>
</div>

<div>
<button
title="Toggle thumbnail shape"
class="rounded-sm bg-gray-800 px-2 text-slate-400 hover:bg-gray-600"
onclick={e => {
const updated_shape = runes.settings.ui.media_list.thumbnail_shape === 'square'
? 'original'
: 'square'
runes.settings.set('ui.media_list.thumbnail_shape', updated_shape)
}}
>{runes.settings.ui.media_list.thumbnail_shape}</button>
</div>

<span class="">
Total results {human_readable_number(runes.series_list.total)}
</span>
</div>
</footer>
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script lang="ts">
import * as theme from '$lib/theme.ts'
import SearchParams from './SearchParams.svelte'
import type { SeriesController } from '../controller.ts'
import Icon from '$lib/components/Icon.svelte'
import { Filter, ChevronUp, ChevronDown } from '$lib/icons/mod.ts'
import TagAutoCompleteInput from "$lib/components/TagAutoCompleteInput.svelte";

let {controller, height = $bindable()}: {controller: SeriesController; height: number} = $props()
let { dimensions, queryparams } = controller.runes

</script>

<header class="drop-shadow-md bg-gray-700 flex justify-center border-b-slate-800 border-b-2 relative z-10" bind:clientHeight={height}>
<title>{queryparams.human_readable_summary || 'Forager'}</title>
<SearchParams {controller} />
</header>
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<script lang="ts">
import * as datetime from '@std/datetime'
import type { Json } from '@andykais/ts-rpc/adapters/sveltekit.ts'

import { BrowseController } from '../controller.ts'
let {controller, ...props}: {
controller: BrowseController
label: string
content: string | number | Date | null | Json
type?: "text" | "datetime-local"
editable?: boolean
hide_if_null?: boolean
} = $props()

let value = $derived.by(() => {
if (props.content === null) return null

if (props.type === 'datetime-local') {
return datetime.format(new Date(props.content as string), 'yyyy-MM-ddTHH:mm')
}

return props.content
})

let display_inline = ['Views', 'Stars'].includes(props.label)
</script>

{#if !(props.hide_if_null && props.content === null)}
<div class={[
"py-1.5",
display_inline ? "grid grid-cols-[1fr_auto]" : ''
]}>
<label class="text-green-50" for="{props.label}"><span>{props.label}</span></label>
<div>
{#if props.editable}
<input
class="bg-slate-400 w-full rounded-sm px-1 text-sm"
type={props.type ?? "text"}
value={value}
>
{:else}
<span
class="bg-slate-400 w-full inline rounded-sm px-1 text-sm text-nowrap select-all py-0.5"
>
{value}
</span>
{/if}
</div>
</div>
{/if}
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<script lang="ts">
import * as path from '@std/path'
import type { Forager } from '@forager/core'
import Sidebar from '$lib/components/Sidebar.svelte'
import MediaDetailEntry from './MediaDetailEntry.svelte'
import TagAutoCompleteInput from '$lib/components/TagAutoCompleteInput.svelte'
import SearchLink from './SearchLink.svelte'
import Tag from '$lib/components/Tag.svelte'
import Icon from '$lib/components/Icon.svelte'
import {XCircle} from '$lib/icons/mod.ts'
import * as parsers from '$lib/parsers.ts'

import { SeriesController } from '../controller.ts'
let {controller}: {controller: SeriesController} = $props()
let {dimensions, media_selections, settings, queryparams} = controller.runes

let new_tag_str = $state<string>('')

type TagRecord = ReturnType<Forager['tag']['search']>['results'][0]
let sorted_tags: [string, TagRecord[]][] = $derived.by(() => {
const grouped_tags: Record<string, TagRecord[]> = {}
media_selections.current_selection.media_response?.tags.map(t => {
if (grouped_tags[t.group] === undefined) {
grouped_tags[t.group] = []
}

grouped_tags[t.group].push(t)
})

const entries = Object.entries(grouped_tags).sort((a, b) => a[0].localeCompare(b[0]))

entries.sort((a, b) => {
const a_index = settings.ui.sidebar.tags.order.findIndex(match => {
return match.group === a[0]
})
const b_index = settings.ui.sidebar.tags.order.findIndex(match => {
return match.group === b[0]
})
if (a_index !== -1 && b_index !== -1) {
return a_index - b_index
} else if (a_index !== -1) {
return -1
} else if (b_index !== -1) {
return 1
}
return a[0].localeCompare(b[0])
})
return entries
})

controller.keybinds.component_listen({
ToggleSidebar: e => {
settings.set('ui.sidebar.hide', !settings.ui.sidebar.hide)
}
})
</script>

<Sidebar
height={dimensions.heights.media_list}
width={settings.ui.sidebar.size}
bind:hide={settings.ui.sidebar.hide}
>
<form
class="pl-1 pr-3"
onsubmit={async e => {
e.preventDefault()
let media_info = undefined
let tags: {add: string[]} | undefined
if (new_tag_str) {
tags = {add: [new_tag_str]}
}
media_selections.current_selection.media_response!.update(media_info, tags)
new_tag_str = ''
}}
>
{#if media_selections.current_selection.media_response}
<MediaDetailEntry
{controller}
label="Views"
content={media_selections.current_selection.media_response.media_reference.view_count}/>

<MediaDetailEntry
{controller}
label="Stars"
content={media_selections.current_selection.media_response.media_reference.stars}/>

<label class="text-green-50" for="tags"><span>Tags</span></label>
{#each sorted_tags as tag_group_entry, tag_entry_index (tag_group_entry[0])}
{#each tag_group_entry[1] as tag, tag_index (tag.id)}
<div class="grid grid-cols-[1fr_auto] items-center gap-1 pb-1">
<SearchLink {controller} params={queryparams.merge({tags: parsers.Tag.encode(tag)})}>
<Tag show_group={false} {tag} />
</SearchLink>
<button
class="hover:cursor-pointer"
title="Remove"
type="button"
onclick={async e => {
await media_selections.current_selection.media_response.update(undefined, {remove: [`${tag.group}:${tag.name}`]})
}}>
<Icon class="fill-green-50 hover:fill-green-300" data={XCircle} size="18px" color="none" />
</button>
</div>
{/each}
{/each}
<TagAutoCompleteInput
{controller}
sort_by="updated_at"
bind:search_string={new_tag_str}
placeholder=""
kind="details"
input_classes="mt-2 bg-slate-400 w-full rounded-sm px-1 text-sm"
/>
<input type="submit" class='hidden'>

<MediaDetailEntry
{controller}
editable
hide_if_null
label="Title"
content={media_selections.current_selection.media_response.media_reference.title}/>

<MediaDetailEntry
{controller}
editable
hide_if_null
label="Description"
content={media_selections.current_selection.media_response.media_reference.description}/>

<MediaDetailEntry
{controller}
editable
hide_if_null
label="Created"
type="datetime-local"
content={media_selections.current_selection.media_response.media_reference.source_created_at}/>

<!-- TODO we should support datetimes in @andykais/ts-rpc -->
<MediaDetailEntry
{controller}
editable
label="Added"
type="datetime-local"
content={media_selections.current_selection.media_response.media_reference.created_at}/>

<MediaDetailEntry
{controller}
hide_if_null
label="Source URL"
content={media_selections.current_selection.media_response.media_reference.source_url}/>

<MediaDetailEntry
{controller}
hide_if_null
label="Metadata"
content={media_selections.current_selection.media_response.media_reference.metadata}/>

{#if media_selections.current_selection.media_response.media_type === 'media_file'}
<MediaDetailEntry
{controller}
label="File"
content={media_selections.current_selection.media_response.media_file.filepath}/>

<MediaDetailEntry
{controller}
label="Filename"
content={path.basename(media_selections.current_selection.media_response.media_file.filepath)}/>
{/if}

{:else}
TODO: show tag summary
{/if}
</form>
</Sidebar>
Loading
Loading