Skip to content

Commit

Permalink
feat: add landing page (#1168)
Browse files Browse the repository at this point in the history
* feat(page): add landing page and proxies
* feat(page): improve search
  • Loading branch information
alexander-heimbuch authored Dec 30, 2024
1 parent 21c54e4 commit 6c8ae28
Show file tree
Hide file tree
Showing 15 changed files with 468 additions and 16 deletions.
3 changes: 2 additions & 1 deletion apps/page/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"scroll-into-view-if-needed": "3.1.0",
"vue-i18n": "9.13.1",
"ndarray": "1.0.19",
"quantize": "1.0.2"
"quantize": "1.0.2",
"@heroicons/vue": "2.2.0"
},
"devDependencies": {
"@types/lodash-es": "4.17.12",
Expand Down
9 changes: 0 additions & 9 deletions apps/page/public/favicon.svg

This file was deleted.

30 changes: 30 additions & 0 deletions apps/page/public/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions apps/page/src/features/PageFooter.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@
<div class="w-full md:w-1/3 truncate text-right">
<a
class="hover:underline"
href="https://podlove.org"
href="https://lux.podlove.org"
target="_blank"
rel="nofollow noopener"
>{{ t('FOOTER.CREATED_WITH', { name: 'Podlove', buildDate }) }}</a
>{{ t('FOOTER.CREATED_WITH', { name: 'Podlove Lux', buildDate }) }}</a
>
</div>
</div>
Expand Down
174 changes: 174 additions & 0 deletions apps/page/src/features/feed-search/FeedSearch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<template>
<div class="max-w-[800px] flex items-center flex-col">
<Search :query="query" @search="search" :loading="loading" class="mb-10" />
<Transition name="slide-fade">
<ul v-if="results.length > 0">
<Item
v-for="podcast in results"
:title="podcast.title"
:description="podcast.description"
:feed="podcast.feed"
:image="podcast.image"
/>
</ul>
</Transition>
<Transition name="slide-fade">
<div
v-if="
results.length === 0 &&
query.length > 0 &&
loading === false &&
feedError === false &&
searchError === false
"
class="border p-4 rounded border-[rgb(228,70,59)]"
>
Podcast not found? Maybe it's registered at
<a class="text-[rgb(56,126,25)]" href="https://fyyd.de/add-feed">fyyd</a> yet? 😊
</div>
</Transition>
<Transition name="slide-fade">
<div
v-if="(searchError || feedError) && !loading"
class="border p-4 rounded border-[rgb(228,70,59)]"
>
<span v-if="feedError">Invalid feed, check the url or search for a Podcast. 😓</span>
<span v-if="searchError"
>Search failed, maybe something is stuck at
<a class="text-[rgb(56,126,25)]" href="https://fyyd.de">fyyd</a> 🤔</span
>
</div>
</Transition>
</div>
</template>

<script setup lang="ts">
import { ref, watch } from 'vue';
import { get } from 'lodash-es';
import Search from './components/Search.vue';
import Item from './components/Item.vue';
import { debounceAsync } from '../../lib/debounce-async';
const query = ref('');
const loading = ref(false);
const searchError = ref(false);
const feedError = ref(false);
const search = (search: string) => {
query.value = search;
};
interface ListItem {
title: string;
feed: string;
image: string | null;
description: string | null;
author: string | null;
}
const results = ref<ListItem[]>([]);
const isValidUrl = (url: string) => {
try {
new URL(url);
return true;
} catch (_err) {
return false;
}
};
const queryFeed = async (url: string, signal: AbortSignal): Promise<ListItem[]> => {
try {
const feed = await fetch(`/api/feed?url=${url}`, { signal }).then((res) => res.json());
return [
{
title: get(feed, ['show', 'title']),
feed: url,
description: get(feed, ['show', 'summary'], null),
image: get(feed, ['show', 'poster'], null),
author: get(feed, ['author', 'owner'], null)
}
];
} catch (err: any) {
if (err.name !== 'AbortError') {
return [];
}
throw err;
}
};
const queryFyyd = async (query: string, signal: AbortSignal): Promise<ListItem[]> => {
try {
const data = await fetch(`https://api.fyyd.de/0.2/search/podcast?title=${query}`, {
signal
}).then((res) => res.json());
return get(data, ['data'], [])
.map((item: any) => ({
title: get(item, 'title', null) as string | null,
image: get(item, 'imgURL', null) as string | null,
feed: get(item, 'xmlURL', null) as string | null,
description: get(item, 'description', null) as string | null,
author: get(item, 'author', null) as string | null
}))
.filter(({ title, feed }: { title: string; feed: string }) => title && feed);
} catch (err: any) {
if (err.name !== 'AbortError') {
return [];
}
throw err;
}
};
const handleInput = debounceAsync(async (query: string, signal: AbortSignal) => {
searchError.value = false;
feedError.value = false;
if (query.length === 0) {
return;
}
if (isValidUrl(query)) {
[results.value, feedError.value] = await queryFeed(query, signal)
.then((results): [ListItem[], boolean] => [results, false])
.catch((err: any) => [[], err.name !== 'AbortError']);
} else {
[results.value, searchError.value] = await queryFyyd(query, signal)
.then((results): [ListItem[], boolean] => [results, false])
.catch((err: any) => [[], err.name !== 'AbortError']);
}
}, 300);
const controller: AbortController | null = new AbortController();
let queryRunning = false;
watch(query, async (value: string) => {
if (queryRunning) {
// controller.abort();
}
loading.value = true;
queryRunning = true;
await handleInput(value, controller.signal)
queryRunning = false;
loading.value = false;
});
</script>

<style>
.slide-fade-enter-active {
transition: all 0.3s ease-out;
}
.slide-fade-leave-active {
transition: all 0.3s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter-from,
.slide-fade-leave-to {
transform: translateY(20px);
}
</style>
20 changes: 20 additions & 0 deletions apps/page/src/features/feed-search/components/Fyyd.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<template>
<svg viewBox="0 0 2515 1098">
<path
d="m11 547c0-301 245-546 546-546s546 245 546 546c0 301-245 546-546 546s-546-245-546-546zm446 355c18 18 46 18 64 0l323-323c18-18 18-46 0-64l-323-323c-18-18-46-18-64 0l-73 73c-18 18-18 46 0 64l218 218-218 218c-18 18-18 46 0 64l73 73z"
fill="#008000"
></path>
<path
d="m1339 260c-13 0-23 3-29 9-7 6-10 17-10 34v26h75v90h-75v344h-119v-467c0-78 37-117 110-117h84v82h-36z"
></path>
<path
d="m1629 328h119v467c0 78-37 117-110 117h-89c-74 0-110-39-110-117v-14h116v29c0 5 2 9 5 12 3 4 7 5 12 5h41c5 0 9-2 12-5 3-4 5-8 5-12v-88c-18 14-42 21-73 21h-19c-74 0-110-39-110-117v-297h119v311c0 5 2 9 5 12 3 4 7 5 12 5h49c5 0 9-2 12-5 3-4 5-8 5-12v-311z"
></path>
<path
d="m2018 328h119v467c0 78-37 117-110 117h-89c-74 0-110-39-110-117v-14h116v29c0 5 2 9 5 12 3 4 7 5 12 5h41c5 0 9-2 12-5 3-4 5-8 5-12v-88c-18 14-42 21-73 21h-19c-74 0-110-39-110-117v-297h119v311c0 5 2 9 5 12 3 4 7 5 12 5h49c5 0 9-2 12-5 3-4 5-8 5-12v-311z"
></path>
<path
d="m2311 319c30 0 55 7 73 21v-154h119v575h-101v-32c-18 27-49 41-92 41h-19c-74 0-110-39-110-117v-217c0-78 37-117 110-117h19zm73 349v-245c0-5-2-9-5-12-3-4-7-5-12-5h-49c-5 0-9 2-12 5-3 4-5 8-5 12v245c0 5 2 9 5 12 3 4 7 5 12 5h49c5 0 9-2 12-5 3-4 5-8 5-12z"
></path>
</svg>
</template>
32 changes: 32 additions & 0 deletions apps/page/src/features/feed-search/components/Item.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<template>
<li class="relative flex justify-between gap-x-6 py-2 podcast-list-item">
<a class="flex min-w-0 gap-x-4 w-full hover:bg-primary-200 p-2 rounded" :href="`/feed/${feed}`">
<podcast-cover class="size-12 flex-none rounded-lg bg-gray-50 mt-1 overflow-hidden shadow-md" :url="image || ''" />
<div class="min-w-0 flex-auto w-full">
<p class="text-sm/6 font-semibold text-gray-900">
<span class="absolute inset-x-0 -top-px bottom-0" />
{{ title }}
</p>
<p class="mt-1 flex text-xs/5 text-gray-500">
{{ description }}
</p>
</div>
<div class="flex shrink-0 items-center gap-x-4">
<ChevronRightIcon class="size-5 flex-none text-gray-400" aria-hidden="true" />
</div>
</a>
</li>
</template>

<script setup lang="ts">
import { ChevronRightIcon } from '@heroicons/vue/20/solid';
import { Image as PodcastCover } from "@podlove/components"
defineProps<{ title: string; image: string | null; feed: string; description: string | null }>();
</script>

<style>
.podcast-list-item {
--podlove-component--image--background: rgba(29, 86, 240, 0.5);
}
</style>
63 changes: 63 additions & 0 deletions apps/page/src/features/feed-search/components/Search.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<template>
<div>
<div class="mt-2 grid grid-cols-1 podcast-search">
<div
class="flex items-center rounded-md bg-white px-3 outline outline-1 -outline-offset-1 outline-gray-300 focus-within:outline focus-within:outline-2 focus-within:-outline-offset-2 focus-within:outline-complementary-600 w-128"
>
<div class="shrink-0 select-none text-base text-gray-500 flex items-center w-6">
<LoadingIcon
v-if="loading"
class="pointer-events-none col-start-1 row-start-1 size-5"
aria-hidden="true"
/>
<MagnifyingGlassIcon
v-else
class="pointer-events-none col-start-1 row-start-1 size-5 text-gray-400"
aria-hidden="true"
/>
</div>
<input
type="text"
class="block min-w-0 w-full grow pt-1 pb-1.5 pl-1.5 pr-1.5 text-gray-900 placeholder:text-gray-400 focus:outline focus:outline-0 text-lg font-extralight"
placeholder="Search for a Podcast or enter a Feed"
:value="query"
@input="input"
@focusin="showPoweredBy = true"
/>
</div>
</div>
<div
class="w-full flex justify-end p-2 transition-opacity opacity-0"
:class="{ 'opacity-100': showPoweredBy }"
>
<span class="text-xs text-gray-400 mr-1">Search powered by</span
><a href="https://fyyd.de/"><Fyyd class="w-11" /></a>
</div>
</div>
</template>

<script setup lang="ts">
import { MagnifyingGlassIcon } from '@heroicons/vue/16/solid';
import { get } from 'lodash-es';
import { LoadingIcon } from '@podlove/components';
import { ref } from 'vue';
import Fyyd from './Fyyd.vue';
defineProps<{ query: string | null; loading: boolean; }>();
const showPoweredBy = ref(false);
const emits = defineEmits(['search']);
const input = (event: Event) => {
const value = get(event, ['target', 'value'], '');
emits('search', value);
};
</script>

<style>
.podcast-search {
--podlove-component--icon--color: rgba(var(--gray-color-400), 1);
}
</style>
22 changes: 22 additions & 0 deletions apps/page/src/lib/debounce-async.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@

export function debounceAsync<T, Callback extends (...args: any[]) => Promise<T>>(
callback: Callback,
wait: number,
): (...args: Parameters<Callback>) => Promise<T> {
let timeoutId: number | null = null;

return (...args: any[]) => {
if (timeoutId) {
clearTimeout(timeoutId);
}

return new Promise<T>((resolve) => {
const timeoutPromise = new Promise<void>((resolve) => {
timeoutId = setTimeout(resolve, wait) as unknown as number;
});
timeoutPromise.then(async () => {
resolve(await callback(...args));
});
});
};
}
Loading

0 comments on commit 6c8ae28

Please sign in to comment.