Update src/

This commit is contained in:
freearhey 2025-04-14 21:53:33 +03:00
parent 09b07e9b24
commit 86743c74f5
132 changed files with 4418 additions and 1907 deletions

View file

@ -1,20 +0,0 @@
<script>
export let disabled = false
export let active = false
</script>
<button
{...$$restProps}
class="rounded-md transition-colors duration-200 border border-transparent text-white text-sm font-semibold text-center h-10 flex items-center justify-center space-x-3 px-4"
class:bg-gray-200={disabled || active}
class:bg-primary-600={!disabled && !active}
class:dark:bg-gray-700={disabled || active}
class:dark:hover:bg-primary-500={!disabled && !active}
class:dark:hover:bg-gray-700={active}
class:hover:bg-gray-200={disabled || active}
class:hover:bg-primary-700={!disabled && !active}
class:pointer-events-none={disabled}
on:click
>
<slot />
</button>

View file

@ -0,0 +1,5 @@
<div
class="text-gray-500 dark:text-gray-300 border-[1px] border-gray-200 text-xs inline-flex items-center h-5.5 px-2 py-0.5 rounded-full"
>
<slot />
</div>

View file

@ -1,49 +1,37 @@
<script>
<script lang="ts">
import { BlocklistRecord, Channel } from '~/models'
import Badge from '~/components/Badge.svelte'
import tippy from 'sveltejs-tippy'
export let channel
export let channel: Channel
let reason
let reason = 'dmca'
const messages = {
dmca: 'The channel has been added to our blocklist due to the claims of the copyright holder',
nsfw: 'The channel has been added to our blocklist due to NSFW content'
}
const blocklistRefs = channel._blocklistRecords
.map(record => {
let refName
const isIssue = /issues|pull/.test(record.ref)
const isAttachment = /github\.zendesk\.com\/attachments\/token/.test(record.ref)
if (isIssue) {
const parts = record.ref.split('/')
const issueId = parts.pop()
refName = `#${issueId}`
} else if (isAttachment) {
const [, filename] = record.ref.match(/\?name=(.*)/) || [null, undefined]
refName = filename
} else {
refName = record.ref.split('/').pop()
}
const blocklistRecordUrls = channel.blocklistRecords
.map((record: BlocklistRecord) => {
reason = record.reason
return `<a class="underline" target="_blank" rel="noreferrer" href="${record.ref}">${refName}</a>`
return `<a class="underline" target="_blank" rel="noreferrer" href="${
record.refUrl
}">${record.getRefLabel()}</a>`
})
.join(', ')
</script>
<div
class="text-gray-500 border-[1px] border-gray-200 text-xs inline-flex items-center px-2.5 py-0.5 dark:text-gray-300 rounded-full"
use:tippy={{
content: `${messages[reason]}: ${blocklistRefs}`,
allowHTML: true,
placement: 'right',
interactive: true
}}
href={channel.blocklist_ref}
target="_blank"
rel="noreferrer"
>
Blocked
</div>
<Badge>
<div
use:tippy={{
content: `${messages[reason]}: ${blocklistRecordUrls}`,
allowHTML: true,
interactive: true
}}
target="_blank"
rel="noreferrer"
>
Blocked
</div>
</Badge>

View file

@ -1,30 +1,28 @@
<script>
import { slide } from 'svelte/transition'
import DownloadButton from '~/components/DownloadButton.svelte'
import SelectAllButton from '~/components/SelectAllButton.svelte'
import Divider from '~/components/Divider.svelte'
import CloseButton from '~/components/CloseButton.svelte'
import { selected } from '~/store'
import { DownloadButton, SelectAllButton, ResetButton, CloseButton } from '~/components'
import { downloadMode } from '~/store'
import { selected } from '~/store'
</script>
<div
transition:slide={{ duration: 200 }}
class="h-16 bg-white dark:bg-gray-800 fixed bottom-0 left-0 right-0 py-2.5 border-t border-t-gray-100 dark:border-t-gray-800"
>
<div class="flex justify-between items-center max-w-5xl mx-auto px-3">
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono">
Selected {$selected.length.toLocaleString()} channel(s)
</div>
<div class="flex space-x-2 items-center">
<SelectAllButton />
<DownloadButton />
<Divider />
<CloseButton
on:click={() => {
downloadMode.set(false)
}}
/>
<div class="px-2 w-full fixed bottom-10 sm:bottom-20 flex justify-center pointer-events-none">
<div
class="h-16 px-3 py-3 w-full min-w-[300px] max-w-[calc(100%-16px)] sm:w-[540px] bg-primary-850 rounded-lg pointer-events-auto"
>
<div class="flex justify-between items-center w-full max-w-7xl">
<div class="text-sm text-gray-300 font-mono pl-2">
{$selected.count()} selected
</div>
<div class="flex space-x-1 sm:space-x-2 items-center">
<ResetButton />
<SelectAllButton />
<DownloadButton />
<CloseButton
onClick={() => {
downloadMode.set(false)
}}
variant="light"
/>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,19 @@
<script lang="ts">
export let label = 'Button'
export let onClick = () => {}
</script>
<button
type="button"
onclick={onClick}
class="w-full text-left rounded-md text-sm h-10 flex items-center text-gray-500 dark:text-gray-400 font-normal hover:bg-gray-100 dark:hover:bg-primary-750 space-x-3 px-2 border border-transparent cursor-pointer"
{...$$restProps}
>
<div class="w-5 h-5 flex items-center justify-center">
<slot name="left" />
</div>
<div class="w-full">{label}</div>
<div>
<slot name="right" />
</div>
</button>

View file

@ -0,0 +1,16 @@
<script>
export let border = false
let className = 'rounded-md bg-white dark:bg-primary-810'
if (border) className += ' border border-gray-200 dark:border-gray-700'
</script>
<div class={className}>
<div class="flex justify-between items-center pt-2 sm:pt-2.5 pl-4 pr-2.5 rounded-t w-full">
<slot name="headerLeft" />
<slot name="headerRight" />
</div>
<div>
<slot name="body" />
</div>
</div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Channel } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let channel: Channel
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'channels:edit',
template: '2_channels_edit.yml',
title: `Edit: ${channel.getUniqueName()}`,
id: channel.id
})
const editUrl = `${endpoint}?${params}`
function _onClick() {
onClick()
window.open(editUrl, '_blank')
}
</script>
<Button onClick={_onClick} label="Edit">
<Icon.Edit slot="left" class="text-gray-400" size={16} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -1,51 +1,36 @@
<script>
<script lang="ts">
import type { Collection } from '@freearhey/core/browser'
import ChannelItem from './ChannelItem.svelte'
import { query } from '~/store'
export let channels = []
export let channels: Collection
let limit = 100
$: channelsDisplay = channels.slice(0, limit)
query.subscribe(() => {
limit = 100
})
function showMore() {
limit += 100
}
</script>
<div class="flex flex-col bg-white dark:bg-gray-800">
<div class="overflow-y-auto scrollbar-hide">
<div class="inline-block min-w-full align-middle">
<div class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<div class="bg-gray-50 dark:bg-gray-700">
<div class="flex">
<div class="w-36 sm:w-[200px] shrink-0"></div>
<div
class="w-[216px] sm:w-80 py-3 px-2 text-xs font-semibold tracking-wider text-left text-gray-400 uppercase dark:text-gray-400 shrink-0"
>
Name
</div>
<div
class="w-52 sm:w-[280px] py-3 px-2 text-xs font-semibold tracking-wider text-left text-gray-400 uppercase dark:text-gray-400"
>
ID
</div>
<div>
<span class="sr-only">Actions</span>
</div>
</div>
</div>
<div>
{#each channelsDisplay as channel, idx (channel)}
<ChannelItem bind:channel />
{/each}
</div>
<div class="flex flex-col bg-white dark:bg-primary-810 rounded-b-md">
<div>
<div class="w-full inline-block min-w-full align-middle">
<div class="min-w-full w-full">
{#each channelsDisplay.all() as channel, index (channel.id)}
<ChannelItem bind:channel />
{/each}
</div>
</div>
</div>
{#if channelsDisplay.length < channels.length}
{#if channelsDisplay.count() < channels.count()}
<button
class="flex border-t border-gray-200 dark:border-gray-700 items-center justify-center h-12 w-full text-blue-500 dark:text-blue-400 hover:bg-gray-50 hover:dark:bg-gray-700 focus-visible:outline-0"
on:click={showMore}>Show More</button
class="flex border-t border-gray-200 dark:border-primary-700 items-center justify-center h-12 w-full text-blue-500 dark:text-blue-400 hover:bg-gray-50 hover:dark:bg-primary-750 focus-visible:outline-0 cursor-pointer"
onclick={showMore}>Show More</button
>
{/if}
</div>

View file

@ -1,45 +1,47 @@
<script>
import { getContext } from 'svelte'
import StreamsPopup from './StreamsPopup.svelte'
import GuidesPopup from './GuidesPopup.svelte'
import ChannelPopup from './ChannelPopup.svelte'
import Checkbox from './Checkbox.svelte'
import BlockedBadge from './BlockedBadge.svelte'
import ClosedBadge from './ClosedBadge.svelte'
<script lang="ts">
import type { Collection } from '@freearhey/core/browser'
import type { Context } from 'svelte-simple-modal'
import { downloadMode, selected } from '~/store'
import { fade } from 'svelte/transition'
import { pushState } from '$app/navigation'
import { fade } from 'svelte/transition'
import { getContext } from 'svelte'
import { pluralize } from '~/utils'
import { Channel } from '~/models'
import * as Icon from '~/icons'
import {
ChannelPopup,
BlockedBadge,
ClosedBadge,
CodeBlock,
FeedPopup,
Checkbox
} from '~/components'
export let channel
export let channel: Channel
const guides = channel._guides
const streams = channel._streams
const displayName = channel._displayName
const { open } = getContext<Context>('simple-modal')
const [name, country] = channel.id.split('.')
const { open } = getContext('simple-modal')
let prevUrl = '/'
const onOpened = () => {
function onOpened() {
prevUrl = window.location.href
pushState(`/channels/${country}/${name}`, {})
pushState(channel.getPagePath(), {})
}
const onClose = () => {
function onClose() {
pushState(prevUrl, {})
}
const showGuides = () =>
function showFeeds() {
open(
GuidesPopup,
{ guides, title: displayName },
FeedPopup,
{ feeds: channel.getFeeds(), channel },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
const showStreams = () =>
open(
StreamsPopup,
{ streams, title: displayName },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
const showChannelData = () => {
}
function showChannelData(event) {
event.preventDefault()
open(
ChannelPopup,
{ channel },
@ -48,138 +50,92 @@
)
}
function pluralize(number, word) {
return number > 1 ? word + 's' : word
}
function onCheckboxChange(event) {
selected.update(arr => {
if (event.detail.state) {
arr.push(channel)
function onCheckboxChange(state: boolean) {
selected.update((selectedChannels: Collection) => {
if (state) {
selectedChannels.push(channel)
} else {
arr = arr.filter(c => c.id !== channel.id)
selectedChannels = selectedChannels.filter(
(selectedChannel: Channel) => selectedChannel.id !== channel.id
)
}
return arr
return selectedChannels
})
}
$: isSelected = !!$selected.find(c => c.id === channel.id)
$: isDisabled = channel.streams === 0
$: isSelected = !!$selected.find((selectedChannel: Channel) => selectedChannel.id === channel.id)
$: isDisabled = channel.getStreams().isEmpty()
</script>
{#if $downloadMode}
<div
transition:fade={{ duration: 200 }}
class="w-14 h-14 shrink-0 flex items-center absolute -left-14"
class="w-12 h-16 shrink-0 flex items-center absolute -left-14"
>
<Checkbox selected={isSelected} disabled={isDisabled} on:change={onCheckboxChange} />
<Checkbox selected={isSelected} disabled={isDisabled} onChange={onCheckboxChange} />
</div>
{/if}
<div
class="border-b last:border-b-0 border-gray-200 dark:border-gray-700 hover:bg-gray-50 hover:dark:bg-gray-700 h-16 flex items-center relative"
class="border-b last:border-b-0 last:rounded-b-md border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-primary-750 min-h-16 sm:h-16 py-2 flex items-center relative"
>
<div class="px-4 sm:pl-10 sm:pr-16 w-36 sm:w-[200px] flex shrink-0 items-center justify-center">
<div class="px-4 sm:pl-10 sm:pr-16 w-28 sm:w-[200px] flex shrink-0 items-center justify-center">
<div class="inline-flex items-center justify-center whitespace-nowrap overflow-hidden">
{#if channel.logo}
{#if channel.logoUrl}
<img
class="block align-middle mx-auto max-w-[6rem] max-h-[2.75rem] text-sm text-gray-400 dark:text-gray-600 cursor-defaults"
class="block align-middle mx-auto max-w-20 max-h-[2.75rem] text-sm text-gray-400 dark:text-gray-600 cursor-defaults"
loading="lazy"
referrerpolicy="no-referrer"
src={channel.logo}
alt={displayName}
src={channel.logoUrl}
alt={channel.getDisplayName()}
/>
{/if}
</div>
</div>
<div class="w-[216px] sm:w-80 px-2 shrink-0">
<div>
<div class="text-left">
<div class="flex space-x-2 items-center">
<a
on:click|preventDefault={showChannelData}
href="/channels/{country}/{name}"
tabindex="0"
class="font-normal text-gray-600 dark:text-white hover:underline hover:text-blue-500 truncate whitespace-nowrap"
title={displayName}
>
{displayName}
</a>
<div class="flex space-x-2">
{#if channel.is_closed}
<ClosedBadge {channel} />
{/if}
{#if channel.is_blocked}
<BlockedBadge {channel} />
{/if}
</div>
</div>
{#if channel.alt_names.length}
<div
class="text-sm text-gray-400 dark:text-gray-400 line-clamp-1"
title={channel.alt_names.join(', ')}
>
{channel.alt_names.join(', ')}
</div>
{/if}
</div>
</div>
</div>
<div class="w-52 sm:w-[280px] px-2">
<div>
<code
class="break-words text-sm text-gray-600 bg-gray-100 dark:text-gray-300 dark:bg-gray-700 px-2 py-1 rounded-sm select-all cursor-text font-mono"
>{channel.id}</code
<div class="w-full sm:w-78 px-2 sm:shrink-0 overflow-hidden sm:overflow-auto">
<div class="flex items-center space-x-2 text-left">
<a
onclick={showChannelData}
href={channel.getPagePath()}
tabindex="0"
class="text-gray-600 dark:text-white hover:underline hover:text-blue-400 truncate whitespace-nowrap"
title={channel.getDisplayName()}
>
{channel.getDisplayName()}
</a>
{#if channel.isClosed()}
<div class="hidden sm:inline">
<ClosedBadge {channel} />
</div>
{/if}
{#if channel.isBlocked()}
<div class="hidden sm:inline">
<BlockedBadge {channel} />
</div>
{/if}
</div>
{#if channel.altNames.notEmpty()}
<div
class="text-sm text-gray-400 dark:text-gray-400 line-clamp-1"
title={channel.altNames.join(', ')}
>
{channel.altNames.join(', ')}
</div>
{/if}
</div>
<div class="w-56 pr-5 sm:w-[206px]">
<div class="w-54 sm:w-[280px] px-4 hidden sm:flex">
<CodeBlock>{channel.id}</CodeBlock>
</div>
<div class="sm:w-full px-3 sm:pl-4 sm:pr-5">
<div class="text-right flex justify-end space-x-3 items-center">
{#if guides.length}
{#if channel.hasFeeds()}
<button
on:click={showGuides}
class="text-sm text-gray-500 dark:text-gray-100 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400"
onclick={showFeeds}
class="text-sm text-gray-400 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M5.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H6a.75.75 0 01-.75-.75V12zM6 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H6zM7.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H8a.75.75 0 01-.75-.75V12zM8 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H8zM9.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V10zM10 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H10zM9.25 14a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V14zM12 9.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V10a.75.75 0 00-.75-.75H12zM11.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H12a.75.75 0 01-.75-.75V12zM12 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H12zM13.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H14a.75.75 0 01-.75-.75V10zM14 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H14z"
/>
<path
fill-rule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clip-rule="evenodd"
/>
</svg>
<div>{guides.length}</div>
<div>{pluralize(guides.length, 'guide')}</div>
</button>
{/if}{#if streams.length}
<button
on:click={showStreams}
class="text-sm text-gray-500 dark:text-gray-100 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"
/>
</svg>
<div>{streams.length}</div>
<div>{pluralize(streams.length, 'stream')}</div>
<Icon.Feed size={20} />
<div>{channel.getFeeds().count()}</div>
<div class="hidden sm:block">{pluralize(channel.getFeeds().count(), 'feed')}</div>
</button>
{/if}
</div>

View file

@ -1,57 +1,72 @@
<script>
import HTMLPreview from '~/components/HTMLPreview.svelte'
import EditButton from '~/components/EditButton.svelte'
import Divider from '~/components/Divider.svelte'
import CloseButton from '~/components/CloseButton.svelte'
import BlockedBadge from './BlockedBadge.svelte'
import ClosedBadge from './ClosedBadge.svelte'
<script lang="ts">
import type { Context } from 'svelte-simple-modal'
import { toast } from '@zerodevx/svelte-toast'
import { getContext } from 'svelte'
import { Channel } from '~/models'
import {
ChannelRemoveButton,
ShareChannelButton,
ChannelEditButton,
CopyLinkButton,
BlockedBadge,
CloseButton,
ClosedBadge,
HTMLPreview,
Popup,
Card,
Menu
} from '~/components'
export let channel
export let channel: Channel
const { close } = getContext('simple-modal')
const isTouchDevice =
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches
const { close } = getContext<Context>('simple-modal')
window.onpopstate = event => {
if (event.target.location.pathname === '/') {
close()
}
}
let isMenuOpened = false
function closeMenu() {
isMenuOpened = false
}
function onLinkCopy() {
toast.push('Link copied to clipboard')
closeMenu()
}
</script>
<div
class="relative px-2 pt-20 pb-24 flex justify-center"
role="presentation"
on:keypress
on:click|self={close}
>
<div class="relative bg-white rounded-lg shadow dark:bg-gray-800 w-full max-w-[820px]">
<div
class="flex justify-between items-center py-3 pl-5 pr-3 md:pr-4 rounded-t border-b dark:border-gray-700"
>
<div class="w-2/3 overflow-hidden">
<div class="flex items-center space-x-3">
<h3 class="text-l font-medium text-gray-900 dark:text-white">{channel._displayName}</h3>
<div class="flex space-x-2">
{#if channel.is_closed}
<ClosedBadge {channel} />
{/if}
{#if channel.is_blocked}
<BlockedBadge {channel} />
{/if}
</div>
</div>
</div>
<div class="inline-flex w-1/3 justify-end space-x-2 items-center">
<EditButton {channel} />
<Divider />
<CloseButton on:click={close} />
<Popup onClose={close}>
<Card>
<div slot="headerLeft">
<div class="text-l font-medium text-gray-900 dark:text-white sm:pl-1 space-x-1">
<span>{channel.getDisplayName()}</span>
{#if channel.isClosed()}
<ClosedBadge {channel} />
{/if}
{#if channel.isBlocked()}
<BlockedBadge {channel} />
{/if}
</div>
</div>
<div class="overflow-y-auto overflow-x-scroll max-w-full scrollbar-hide">
<div class="inline-table px-5 py-5 sm:py-10 sm:px-12">
<HTMLPreview data={channel} {close} />
</div>
<div slot="headerRight" class="inline-flex w-30 shrink-0 items-center justify-end">
{#if isTouchDevice}
<ShareChannelButton {channel} />
{/if}
<Menu bind:isOpened={isMenuOpened}>
<CopyLinkButton link={channel.getPageUrl()} onCopy={onLinkCopy} />
<ChannelEditButton {channel} onClick={closeMenu} />
<ChannelRemoveButton {channel} onClick={closeMenu} />
</Menu>
<CloseButton onClick={close} />
</div>
</div>
</div>
<div slot="body" class="pt-4 pb-3 px-4 sm:py-9 sm:px-11">
<HTMLPreview fieldset={channel.getFieldset()} onClick={close} />
</div>
</Card>
</Popup>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Channel } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let channel: Channel
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'channels:remove',
template: '3_channels_remove.yml',
title: `Edit: ${channel.getUniqueName()}`,
id: channel.id
})
const url = `${endpoint}?${params}`
function _onClick() {
onClick()
window.open(url, '_blank')
}
</script>
<Button onClick={_onClick} label="Remove">
<Icon.Remove slot="left" class="text-gray-400" size={20} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -1,69 +1,49 @@
<script>
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
<script lang="ts">
import tippy from 'sveltejs-tippy'
import * as Icon from '~/icons'
export let selected = false
export let indeterminate = false
export let disabled = false
function toggle(state) {
dispatch('change', { state })
}
export let onChange = (state: boolean) => {}
</script>
{#if selected}
<button
class="w-12 h-12 rounded-full text-primary-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 flex items-center justify-center"
class="w-12 h-12 rounded-full text-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 flex items-center justify-center cursor-pointer"
aria-label="Unselect"
on:click={() => toggle(false)}
onclick={() => onChange(false)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path
fill-rule="evenodd"
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm13.36-1.814a.75.75 0 10-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.14-.094l3.75-5.25z"
clip-rule="evenodd"
/>
</svg>
<Icon.CheckboxChecked size={24} />
</button>
{:else if indeterminate}
<button
class="w-12 h-12 rounded-full text-primary-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 flex items-center justify-center"
class="w-12 h-12 rounded-full text-blue-500 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 flex items-center justify-center cursor-pointer"
aria-label="Unselect"
on:click={() => toggle(false)}
onclick={() => onChange(false)}
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm3 10.5a.75.75 0 000-1.5H9a.75.75 0 000 1.5h6z"
clip-rule="evenodd"
/>
</svg>
<Icon.CheckboxIndeterminate size={24} />
</button>
{:else if disabled}
<div
class="w-12 h-12 rounded-full text-gray-200 dark:text-gray-700 transition-colors duration-200 flex items-center justify-center"
class="w-12 h-12 rounded-full text-primary-200 dark:text-primary-700 transition-colors duration-200 flex items-center justify-center"
aria-label="Disabled"
title="No link to the broadcast"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<circle cx="12" cy="12" r="3" />
</svg>
<div
use:tippy={{
content: 'No links available',
placement: 'right'
}}
>
<Icon.CheckboxDisabled size={24} />
</div>
</div>
{:else}
<button
class="w-12 h-12 rounded-full text-gray-200 hover:text-gray-400 dark:text-gray-700 dark:hover:text-gray-600 transition-colors duration-200 flex items-center justify-center"
class="w-12 h-12 rounded-full text-primary-200 hover:text-primary-400 dark:text-primary-700 dark:hover:text-primary-600 transition-colors duration-200 flex items-center justify-center cursor-pointer"
aria-label="Select"
on:click={() => toggle(true)}
onclick={() => onChange(true)}
>
<svg
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
class="w-6 h-6"
stroke="currentColor"
stroke-width="1.5"
>
<circle cx="12" cy="12" r="10" fill="none" />
</svg>
<Icon.CheckboxUnchecked size={24} />
</button>
{/if}

View file

@ -1,9 +1,8 @@
<script>
import { onMount, tick, createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
import { tick } from 'svelte'
export let text
export let onCopy = () => {}
let textarea
@ -12,7 +11,7 @@
document.execCommand('Copy')
await tick()
textarea.blur()
dispatch('copy')
onCopy()
}
</script>

View file

@ -1,13 +1,11 @@
<script>
import SquareButton from '~/components/SquareButton.svelte'
import IconButton from '~/components/IconButton.svelte'
import * as Icon from '~/icons'
export let variant = 'default'
export let onClick
</script>
<SquareButton on:click aria-label="Close">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</SquareButton>
<IconButton {onClick} aria-label="Close" title="Close" {variant}>
<Icon.Close size={20} />
</IconButton>

View file

@ -1,17 +1,19 @@
<script>
<script lang="ts">
import Badge from '~/components/Badge.svelte'
import tippy from 'sveltejs-tippy'
import { Channel } from '~/models'
export let channel
export let channel: Channel
</script>
<div
class="text-gray-500 border-[1px] border-gray-200 text-xs inline-flex items-center px-2.5 py-0.5 dark:text-gray-300 cursor-default rounded-full"
use:tippy={{
content: `closed: ${channel.closed}`,
allowHTML: true,
placement: 'right',
interactive: true
}}
>
Closed
</div>
<Badge>
<div
use:tippy={{
content: `closed: ${channel.closedDateString}`,
allowHTML: true,
interactive: true
}}
>
Closed
</div>
</Badge>

View file

@ -0,0 +1,7 @@
<div>
<code
class="break-words text-sm text-gray-500 bg-gray-100 dark:text-gray-300 dark:bg-primary-750 px-2 py-1 rounded-sm select-all cursor-text font-mono"
>
<slot />
</code>
</div>

View file

@ -0,0 +1,14 @@
<script lang="ts">
import Clipboard from '~/components/Clipboard.svelte'
import Button from '~/components/Button.svelte'
import * as Icon from '~/icons'
export let link: string
export let onCopy = () => {}
</script>
<Clipboard text={link} {onCopy} let:copy>
<Button onClick={copy} label="Copy Link">
<Icon.Link slot="left" class="text-gray-400" size={15} />
</Button>
</Clipboard>

View file

@ -1,46 +1,46 @@
<script>
<script lang="ts">
import Clipboard from '~/components/Clipboard.svelte'
import * as Icon from '~/icons'
export let text
let showTooltip = false
export let text: string
export let title = 'Copy to Clipboard'
let isCompleted = false
function onSuccess() {
showTooltip = true
isCompleted = true
setTimeout(() => {
showTooltip = false
isCompleted = false
}, 2000)
}
</script>
<Clipboard {text} on:copy={onSuccess} let:copy>
<Clipboard {text} onCopy={onSuccess} let:copy>
<button
type="button"
on:click={copy}
class="relative flex items-center justify-center text-xs text-gray-500 dark:text-gray-100 w-7 h-7"
aria-label="Copy to Clipboard"
onclick={copy}
disabled={isCompleted}
class="relative flex items-center justify-center text-xs w-7 h-7"
class:cursor-pointer={!isCompleted}
aria-label={title}
{title}
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
></path>
</svg>
<span class="hidden">Copy to Clipboard</span>
<div
role="tooltip"
class:hidden={!showTooltip}
class="tooltip inline-block absolute right-10 top-0 py-2 px-3 text-xs text-gray-100 rounded-md bg-black"
>
Copied!
<div class="text-gray-400">
{#if isCompleted}
<Icon.Check size={20} />
{:else}
<Icon.Copy size={20} />
{/if}
</div>
<span class="hidden">Copy to Clipboard</span>
{#if isCompleted}
<div
role="tooltip"
class="tooltip absolute right-10 top-0 py-2 px-3 text-xs text-gray-100 rounded-md bg-black"
>
Copied!
</div>
{/if}
</button>
</Clipboard>

View file

@ -1,95 +1,124 @@
<script>
import ChannelGrid from './ChannelGrid.svelte'
import Checkbox from './Checkbox.svelte'
import { downloadMode, selected } from '~/store'
import _ from 'lodash'
<script lang="ts">
import { downloadMode, selected, hasQuery, searchResults, isReady } from '~/store'
import { ChannelGrid, Checkbox } from '~/components'
import { Collection } from '@freearhey/core/browser'
import { Channel, Country } from '~/models'
import { fade } from 'svelte/transition'
import * as Icon from '~/icons'
export let country
export let channels
export let hasQuery
export let country: Country
$: countryChannels = Array.isArray(channels) ? channels : []
$: hasStreams = countryChannels.filter(c => c.streams > 0)
$: expanded = country.expanded || (countryChannels && countryChannels.length > 0 && hasQuery)
$: intersect = _.intersectionBy($selected, hasStreams, 'id')
$: isIndeterminate = intersect.length !== 0 && intersect.length < hasStreams.length
$: isDisabled = hasStreams.length === 0
$: isSelected = intersect.length === hasStreams.length && hasStreams.length > 0
let isDisabled = false
let isSelected = false
let isIndeterminate = false
let isExpanded = false
function onExpand() {
country.expanded = !country.expanded
const channels: Collection = country.getChannels()
const channelsWithStreams: Collection = country.getChannelsWithStreams()
let filteredChannelsWithStreams: Collection = channelsWithStreams
let filteredChannels: Collection = channels
let selectedChannels: Collection = $selected
searchResults.subscribe((_searchResults: Collection) => {
onSearchResultsChange(_searchResults)
})
function onSearchResultsChange(_searchResults: Collection) {
isExpanded = false
if ($hasQuery) {
if ($isReady) isExpanded = true
if (_searchResults.isEmpty()) {
filteredChannels = new Collection()
} else {
filteredChannels = channels.intersectsBy(_searchResults, (channel: Channel) => channel.id)
}
} else {
filteredChannels = channels
}
filteredChannelsWithStreams = filteredChannels.filter((channel: Channel) =>
channel.hasStreams()
)
updateState()
}
function onCheckboxChange(event) {
hasStreams.forEach(channel => {
selected.update(arr => {
if (event.detail.state) {
arr.push(channel)
} else {
arr = arr.filter(c => c.id !== channel.id)
}
selected.subscribe((_selected: Collection) => {
selectedChannels = _selected
updateState()
})
return arr
})
})
function updateState() {
const selectedCountryChannels = filteredChannels.intersectsBy(
selectedChannels,
(channel: Channel) => channel.id
)
isDisabled = filteredChannelsWithStreams.isEmpty()
isSelected =
selectedCountryChannels.count() === filteredChannelsWithStreams.count() &&
filteredChannelsWithStreams.notEmpty()
isIndeterminate = selectedCountryChannels.count() > 0
}
function selectAll(state: boolean) {
if (state) {
const _selected = $selected.concat(filteredChannelsWithStreams)
selected.set(_selected)
} else {
const _selected = $selected.filter((channel: Channel) => channel.countryCode !== country.code)
selected.set(_selected)
}
}
function toggleExpanded() {
isExpanded = !isExpanded
}
</script>
<div class="mb-2 md:mb-3" class:pl-14={$downloadMode} style="transition: padding-left 100ms">
<h2 id="accordion-heading-{country.code}" class="flex relative">
{#if $downloadMode}
<div
transition:fade={{ duration: 200 }}
class="w-14 h-14 shrink-0 flex items-center absolute -left-14"
{#if filteredChannels.notEmpty()}
<div class="mb-2 md:mb-3" class:pl-14={$downloadMode} style="transition: padding-left 100ms">
<div id="accordion-heading-{country.code}" class="flex relative">
{#if $downloadMode}
<div
transition:fade={{ duration: 200 }}
class="w-12 h-13 shrink-0 flex items-center absolute -left-14"
>
<Checkbox
selected={isSelected}
disabled={isDisabled}
indeterminate={isIndeterminate}
onChange={selectAll}
/>
</div>
{/if}
<button
onclick={toggleExpanded}
type="button"
class="flex items-center focus:ring-0 dark:focus:ring-gray-800 justify-between h-13 pl-3.5 pr-4 w-full font-medium text-left border border-gray-200 dark:border-primary-750 text-gray-500 dark:text-white bg-white dark:bg-primary-810 cursor-pointer"
class:rounded-t-md={isExpanded}
class:rounded-md={!isExpanded}
class:border-b-transparent={isExpanded}
class:dark:border-b-transparent={isExpanded}
aria-expanded={isExpanded}
aria-controls="accordion-body-{country.code}"
>
<Checkbox
selected={isSelected}
disabled={isDisabled}
indeterminate={isIndeterminate}
on:change={onCheckboxChange}
/>
<span>{country.flagEmoji}&nbsp;{country.name}</span>
<div class="text-gray-400" class:rotate-180={isExpanded}>
<Icon.Expand size={20} />
</div>
</button>
</div>
{#if isExpanded}
<div
class="relative"
id="accordion-body-{country.code}"
aria-labelledby="accordion-heading-{country.code}"
>
<div class="border border-gray-200 dark:border-primary-750 rounded-b-md">
<ChannelGrid channels={filteredChannels} />
</div>
</div>
{/if}
<button
on:click={onExpand}
type="button"
class="flex items-center focus:ring-0 dark:focus:ring-gray-800 justify-between p-4 w-full font-medium text-left border border-gray-200 dark:border-gray-700 text-gray-900 dark:text-white bg-white dark:bg-gray-800"
class:rounded-t-md={expanded}
class:rounded-md={!expanded}
class:border-b-0={expanded}
aria-expanded={expanded}
aria-controls="accordion-body-{country.code}"
>
<span>{country.flag}&nbsp;{country.name}</span>
{#if !hasQuery}
<svg
class:rotate-180={expanded}
class="w-6 h-6 shrink-0"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
{/if}
</button>
</h2>
{#if expanded}
<div
class="relative"
id="accordion-body-{country.code}"
aria-labelledby="accordion-heading-{country.code}"
>
<div
class="border border-gray-200 dark:border-gray-700 dark:bg-gray-900 rounded-b-md overflow-hidden"
>
<ChannelGrid bind:channels />
</div>
</div>
{/if}
</div>
</div>
{/if}

View file

@ -1,34 +1,21 @@
<script>
import IconButton from '~/components/IconButton.svelte'
import { downloadMode } from '~/store'
import SquareButton from '~/components/SquareButton.svelte'
import { createEventDispatcher } from 'svelte'
import * as Icon from '~/icons'
const dispatch = createEventDispatcher()
export let onClick = () => {}
</script>
<SquareButton
<IconButton
active={$downloadMode}
on:click={() => {
onClick={() => {
downloadMode.set(!$downloadMode)
dispatch('click')
onClick()
}}
aria-label="Create playlist"
title="Create playlist"
>
<span class="inline">
<svg
fill="currentColor"
class="w-6 h-6"
clip-rule="evenodd"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><path
d="m17.5 11c2.484 0 4.5 2.016 4.5 4.5s-2.016 4.5-4.5 4.5-4.5-2.016-4.5-4.5 2.016-4.5 4.5-4.5zm.5 4v-1.5c0-.265-.235-.5-.5-.5s-.5.235-.5.5v1.5h-1.5c-.265 0-.5.235-.5.5s.235.5.5.5h1.5v1.5c0 .265.235.5.5.5s.5-.235.5-.5c0-.592 0-1.5 0-1.5h1.5c.265 0 .5-.235.5-.5s-.235-.5-.5-.5c-.592 0-1.5 0-1.5 0zm-6.479 1c.043.522.153 1.025.321 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm1.106-4c-.328.456-.594.96-.785 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm7.373-3.25c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75zm0-4c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75z"
fill-rule="nonzero"
/></svg
>
<Icon.CreatePlaylist size={20} />
</span>
</SquareButton>
</IconButton>

View file

@ -1,8 +0,0 @@
<button
{...$$restProps}
type="button"
on:click
class="rounded-lg text-sm h-10 flex items-center justify-center text-gray-500 dark:text-gray-400 font-normal hover:bg-gray-100 dark:hover:bg-gray-700 space-x-3 px-4 border border-transparent"
>
<slot />
</button>

View file

@ -1 +0,0 @@
<span class="w-[1px] h-[22px] bg-gray-200 dark:bg-gray-700"></span>

View file

@ -1,48 +1,58 @@
<script>
import ActionButton from './ActionButton.svelte'
import { selected, createPlaylist } from '~/store'
<script lang="ts">
import IconButton from '~/components/IconButton.svelte'
import { Collection } from '@freearhey/core/browser'
import { Channel, Stream } from '~/models'
import { PlaylistCreator } from '~/core'
import { selected } from '~/store'
import * as Icon from '~/icons'
const playlistCreator = new PlaylistCreator()
function onClick() {
const playlist = createPlaylist()
const a = createDownloadLink(playlist.toString())
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
let streams = new Collection()
$selected.forEach((channel: Channel) => {
channel.getStreams().forEach((stream: Stream) => {
streams.add(stream)
})
})
streams = streams
.orderBy(
[
(stream: Stream) => stream.channelId.toLowerCase(),
(stream: Stream) => stream.getVerticalResolution(),
(stream: Stream) => stream.url
],
['asc', 'desc', 'asc']
)
.uniqBy((stream: Stream) => stream.channelId || stream.getUUID())
const playlist = playlistCreator.create(streams)
const downloadLink = createDownloadLink(playlist.toString())
document.body.appendChild(downloadLink)
downloadLink.click()
document.body.removeChild(downloadLink)
}
function createDownloadLink(string) {
const blob = new Blob([string], { type: 'text/plain' })
const url = window.URL || window.webkitURL
const link = url.createObjectURL(blob)
const objUrl = url.createObjectURL(blob)
const a = document.createElement('a')
a.setAttribute('download', `playlist.m3u`)
a.setAttribute('href', link)
a.setAttribute('href', objUrl)
return a
}
</script>
<ActionButton on:click={onClick} disabled={!$selected.length} aria-label="Download Playlist">
<span class="inline">
<svg
fill="currentColor"
class="w-4 h-4"
viewBox="0 0 411 411"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_4_46)">
<path
d="M205.5 297.333C202.075 297.333 198.864 296.802 195.867 295.74C192.87 294.678 190.087 292.855 187.519 290.269L95.0438 197.794C90.3344 193.084 87.9797 187.091 87.9797 179.813C87.9797 172.534 90.3344 166.541 95.0438 161.831C99.7531 157.122 105.858 154.664 113.359 154.459C120.86 154.253 126.956 156.497 131.648 161.189L179.812 209.353V25.6876C179.812 18.4095 182.278 12.3044 187.21 7.3724C192.142 2.4404 198.239 -0.0170361 205.5 8.88839e-05C212.778 8.88839e-05 218.883 2.46609 223.815 7.39809C228.747 12.3301 231.205 18.4266 231.187 25.6876V209.353L279.352 161.189C284.061 156.48 290.166 154.228 297.667 154.433C305.167 154.639 311.264 157.105 315.956 161.831C320.666 166.541 323.02 172.534 323.02 179.813C323.02 187.091 320.666 193.084 315.956 197.794L223.481 290.269C220.912 292.837 218.13 294.661 215.133 295.74C212.136 296.819 208.925 297.35 205.5 297.333ZM51.375 411C37.2469 411 25.1481 405.965 15.0786 395.896C5.0091 385.826 -0.0170814 373.736 4.36121e-05 359.625V308.25C4.36121e-05 300.972 2.46605 294.867 7.39804 289.935C12.33 285.003 18.4265 282.545 25.6875 282.562C32.9657 282.562 39.0707 285.028 44.0027 289.96C48.9347 294.892 51.3921 300.989 51.375 308.25V359.625H359.625V308.25C359.625 300.972 362.091 294.867 367.023 289.935C371.955 285.003 378.051 282.545 385.312 282.562C392.591 282.562 398.696 285.028 403.628 289.96C408.56 294.892 411.017 300.989 411 308.25V359.625C411 373.753 405.965 385.852 395.896 395.921C385.826 405.991 373.736 411.017 359.625 411H51.375Z"
/>
</g>
<defs>
<clipPath id="clip0_4_46">
<rect width="411" height="411" />
</clipPath>
</defs>
</svg>
</span>
<span class="hidden md:inline">Download</span>
</ActionButton>
<IconButton
{onClick}
disabled={!$selected.count()}
aria-label="Download Playlist"
title="Download Playlist"
variant="light"
>
<Icon.Download size={16} />
</IconButton>

View file

@ -1,74 +0,0 @@
<script>
import DefaultButton from '~/components/DefaultButton.svelte'
import SquareButton from '~/components/SquareButton.svelte'
import qs from 'qs'
export let channel
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const title = `Edit: ${channel._displayName}`
const labels = 'channels:edit'
const template = '2_channels_edit.yml'
let is_nsfw = null
if (channel.is_nsfw === true) is_nsfw = 'TRUE'
else if (channel.is_nsfw === false) is_nsfw = 'FALSE'
// let params = {
// labels,
// template,
// title,
// id: channel.id,
// name: channel.name,
// alt_names: channel.alt_names.join(';'),
// network: channel.network,
// owners: channel.owners.join(';'),
// country: channel.country,
// subdivision: channel.subdivision,
// city: channel.city,
// broadcast_area: channel.broadcast_area.join(';'),
// languages: channel.languages.join(';'),
// categories: channel.categories.join(';'),
// is_nsfw,
// launched: channel.launched,
// closed: channel.closed,
// replaced_by: channel.replaced_by,
// website: channel.website,
// logo: channel.logo
// }
let params = {
labels,
template,
title,
id: channel.id
}
params = qs.stringify(params)
const editUrl = `${endpoint}?${params}`
function goToEdit() {
window.open(editUrl, '_blank')
}
</script>
<div class="hidden md:block">
<DefaultButton on:click={goToEdit}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
/>
</svg>
<span>Edit</span>
</DefaultButton>
</div>
<div class="block md:hidden">
<SquareButton on:click={goToEdit} aria-label="Edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
<path
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
/>
</svg>
</SquareButton>
</div>

View file

@ -1,27 +1,20 @@
<script>
import { createEventDispatcher } from 'svelte'
<script lang="ts">
import IconButton from './IconButton.svelte'
import * as Icon from '~/icons'
const dispatch = createEventDispatcher()
let expanded = false
export let expanded = false
export let onClick = (state: boolean) => {}
</script>
<button
class="w-7 h-7 flex justify-center align-middle text-gray-500 hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-600 shrink-0 items-center"
on:click={() => {
<IconButton
onClick={() => {
expanded = !expanded
dispatch('click', { state: expanded })
onClick(expanded)
}}
size={32}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
<svg
class="w-4 h-4"
class:rotate-90={expanded}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
<div class:rotate-180={expanded}>
<Icon.Expand size={20} />
</div>
</IconButton>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Channel } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let channel: Channel
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'feeds:add',
template: '4_feeds_add.yml',
title: 'Add: ',
channel_id: channel.id
})
const url = `${endpoint}?${params}`
function _onClick() {
window.open(url, '_blank')
onClick()
}
</script>
<Button onClick={_onClick} label="Add feed">
<Icon.Add slot="left" class="text-gray-400" size={20} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Feed } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let feed: Feed
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'feeds:edit',
template: '5_feeds_edit.yml',
title: `Edit: ${feed.getDisplayName()}`,
feed_id: feed.id,
channel_id: feed.channelId
})
const editUrl = `${endpoint}?${params}`
function _onClick() {
window.open(editUrl, '_blank')
onClick()
}
</script>
<Button onClick={_onClick} label="Edit">
<Icon.Edit slot="left" class="text-gray-400" size={16} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -0,0 +1,109 @@
<script lang="ts">
import type { Context } from 'svelte-simple-modal'
import { toast } from '@zerodevx/svelte-toast'
import { getContext } from 'svelte'
import { page } from '$app/state'
import * as Icon from '~/icons'
import { Feed } from '~/models'
import {
FeedRemoveButton,
CopyLinkButton,
FeedEditButton,
ExpandButton,
StreamsPopup,
HTMLPreview,
GuidesPopup,
CodeBlock,
Menu
} from '~/components'
export let feed: Feed
export let onClose = () => {}
const modal = getContext<Context>('simple-modal')
const hash = page.url.hash.replace('#', '').toLowerCase()
let isExpanded = (!hash && feed.isMain) || hash === feed.id.toLowerCase()
function showGuides() {
modal.open(
GuidesPopup,
{ guides: feed.getGuides(), title: 'Guides' },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
}
function showStreams() {
modal.open(
StreamsPopup,
{ streams: feed.getStreams(), title: 'Streams' },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
}
function _onClose() {
modal.close()
onClose()
}
let isMenuOpened = false
function closeMenu() {
isMenuOpened = false
}
function onLinkCopy() {
toast.push('Link copied to clipboard')
closeMenu()
}
</script>
<div class="w-full rounded-md border border-gray-200 dark:border-gray-700" id={feed.id}>
<div
class="w-full inline-flex justify-between px-2 py-1.5 border-gray-200 dark:border-gray-700"
class:border-b={isExpanded}
>
<div class="flex items-center w-full">
<div class="flex items-center w-full max-w-52 space-x-2 pr-3">
<ExpandButton bind:expanded={isExpanded} />
<div class="w-full text-gray-600 dark:text-white truncate">{feed.name}</div>
</div>
<div class="w-full hidden sm:flex">
<CodeBlock>{feed.id}</CodeBlock>
</div>
<div class="text-right flex justify-end items-center w-full">
<div class="flex space-x-5 items-center px-2 h-10">
{#if feed.hasStreams()}
<button
onclick={showStreams}
class="text-sm text-gray-400 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
title="Streams"
>
<Icon.Stream size={20} />
<div>{feed.getStreams().count()}</div>
</button>
{/if}
{#if feed.hasGuides()}
<button
onclick={showGuides}
class="text-sm text-gray-400 inline-flex space-x-1 flex items-center hover:text-blue-500 dark:hover:text-blue-400 cursor-pointer"
title="Streams"
>
<Icon.Guide size={20} />
<div>{feed.getGuides().count()}</div>
</button>
{/if}
</div>
<Menu bind:isOpened={isMenuOpened}>
<CopyLinkButton link={feed.getPageUrl()} onCopy={onLinkCopy} />
<FeedEditButton {feed} onClick={closeMenu} />
<FeedRemoveButton {feed} onClick={closeMenu} />
</Menu>
</div>
</div>
</div>
{#if isExpanded}
<div class="w-full flex px-6 py-6">
<HTMLPreview fieldset={feed.getFieldset()} onClick={_onClose} />
</div>
{/if}
</div>

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { Popup, Card, Menu, FeedAddButton, CloseButton } from '~/components'
import { Collection } from '@freearhey/core/browser'
import type { Context } from 'svelte-simple-modal'
import type { Channel, Feed } from '~/models'
import FeedItem from './FeedItem.svelte'
import Modal from 'svelte-simple-modal'
import { getContext } from 'svelte'
import * as Icon from '~/icons'
export let channel: Channel
export let feeds: Collection = new Collection()
feeds = feeds.orderBy(
[(feed: Feed) => (feed.isMain ? 1 : 0), (feed: Feed) => feed.id],
['desc', 'asc']
)
const { close } = getContext<Context>('simple-modal')
let isMenuOpened = false
function closeMenu() {
isMenuOpened = false
}
</script>
<Popup onClose={close}>
<Card>
<div
slot="headerLeft"
class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center"
>
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<Icon.Feed size={21} />
</span>{channel.getDisplayName()}
</div>
<div slot="headerRight" class="inline-flex">
<Menu bind:isOpened={isMenuOpened}>
<FeedAddButton {channel} onClick={closeMenu} />
</Menu>
<CloseButton onClick={close} />
</div>
<div slot="body" class="flex flex-col gap-2 p-2 sm:p-5">
<Modal
unstyled={true}
classBg="fixed top-0 left-0 z-80 w-screen h-screen flex flex-col bg-black/70 overflow-y-scroll"
closeButton={false}
>
{#each feeds.all() as feed, index (feed.getUUID())}
<FeedItem {feed} onClose={close} />
{/each}
</Modal>
</div>
</Card>
</Popup>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import Button from '~/components/Button.svelte'
import type { Feed } from '~/models'
import * as Icon from '~/icons'
import qs from 'qs'
export let feed: Feed
export let onClick = () => {}
const endpoint = 'https://github.com/iptv-org/database/issues/new'
const params = qs.stringify({
labels: 'feeds:remove',
template: '6_feeds_remove.yml',
title: `Edit: ${feed.getDisplayName()}`,
feed_id: feed.id,
channel_id: feed.channelId
})
const url = `${endpoint}?${params}`
function _onClick() {
window.open(url, '_blank')
onClick()
}
</script>
<Button onClick={_onClick} label="Remove">
<Icon.Remove slot="left" class="text-gray-400" size={20} />
<Icon.ExternalLink slot="right" class="text-gray-400 dark:text-gray-500" size={17} />
</Button>

View file

@ -1,24 +1,12 @@
<script>
import SquareButton from '~/components/SquareButton.svelte'
import IconButton from '~/components/IconButton.svelte'
import * as Icon from '~/icons'
function onClick() {
window.open('https://github.com/iptv-org/', '_blank', 'noreferrer')
}
</script>
<SquareButton
on:click={() => {
window.open('https://github.com/iptv-org/', '_blank', 'noreferrer')
}}
aria-label="GitHub"
>
<svg
class="w-5 h-5"
aria-hidden="true"
focusable="false"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 496 512"
>
<path
fill="currentColor"
d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
></path>
</svg>
</SquareButton>
<IconButton {onClick} aria-label="GitHub">
<Icon.GitHub size={20} />
</IconButton>

View file

@ -1,42 +1,30 @@
<script>
export let guide
<script lang="ts">
import { Guide } from '~/models'
import * as Icon from '~/icons'
const url = `https://${guide.site}`
export let guide: Guide
</script>
<div
class="w-full inline-flex justify-between px-4 border-b-[1px] dark:border-gray-700 last:border-0"
class="w-full inline-flex justify-between px-4 border-b-[1px] border-gray-200 dark:border-gray-700 last:border-0"
>
<div class="flex space-x-4 items-center w-full min-h-11 py-3">
<div class="text-gray-400 w-8 text-sm">{guide.lang}</div>
<div class="text-gray-400 w-8 text-sm">{guide.languageCode}</div>
<a
class="whitespace-nowrap text-sm text-gray-600 dark:text-gray-100 hover:text-blue-500 hover:underline inline-flex align-middle max-w-[50%] w-full"
href={url}
title={url}
class="whitespace-nowrap text-sm text-gray-600 dark:text-gray-100 hover:text-blue-500 hover:underline inline-flex align-middle max-w-[50%] w-full items-center space-x-1"
href={guide.getUrl()}
title={guide.getUrl()}
target="_blank"
rel="noreferrer"
>
<span class="truncate">{guide.site}</span><span
class="inline-flex items-center pl-1 font-semibold text-gray-500 rounded-full"
<span class="truncate">{guide.siteDomain}</span><span
class="text-sm text-gray-400 dark:text-gray-500"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
></path>
</svg>
<Icon.ExternalLink size={16} />
</span></a
>
<div class="text-right text-gray-400 text-sm w-full" title={guide.site_id}>
{guide.site_name}
<div class="text-right text-gray-400 text-sm w-full" title={guide.siteId}>
{guide.siteName}
</div>
</div>
</div>

View file

@ -1,70 +1,38 @@
<script>
import GuideItem from '~/components/GuideItem.svelte'
<script lang="ts">
import { CloseButton, GuideItem, Popup, Card } from '~/components'
import { Collection } from '@freearhey/core/browser'
import type { Context } from 'svelte-simple-modal'
import { getContext } from 'svelte'
const { close } = getContext('simple-modal')
import * as Icon from '~/icons'
export let title = 'Guides'
export let guides = []
export let guides: Collection = new Collection()
const { close } = getContext<Context>('simple-modal')
</script>
<div
class="relative px-2 py-32 flex justify-center"
role="presentation"
on:keypress
on:click|self={close}
>
<div class="relative bg-white rounded-md shadow dark:bg-gray-800 w-full max-w-2xl">
<Popup onClose={() => close()} wrapperClass="flex justify-center p-2 pt-16 sm:py-44 z-50">
<Card>
<div
class="flex justify-between items-center py-4 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
slot="headerLeft"
class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center"
>
<h3 class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center">
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M5.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H6a.75.75 0 01-.75-.75V12zM6 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H6zM7.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H8a.75.75 0 01-.75-.75V12zM8 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H8zM9.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V10zM10 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H10zM9.25 14a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H10a.75.75 0 01-.75-.75V14zM12 9.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V10a.75.75 0 00-.75-.75H12zM11.25 12a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H12a.75.75 0 01-.75-.75V12zM12 13.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V14a.75.75 0 00-.75-.75H12zM13.25 10a.75.75 0 01.75-.75h.01a.75.75 0 01.75.75v.01a.75.75 0 01-.75.75H14a.75.75 0 01-.75-.75V10zM14 11.25a.75.75 0 00-.75.75v.01c0 .414.336.75.75.75h.01a.75.75 0 00.75-.75V12a.75.75 0 00-.75-.75H14z"
/>
<path
fill-rule="evenodd"
d="M5.75 2a.75.75 0 01.75.75V4h7V2.75a.75.75 0 011.5 0V4h.25A2.75 2.75 0 0118 6.75v8.5A2.75 2.75 0 0115.25 18H4.75A2.75 2.75 0 012 15.25v-8.5A2.75 2.75 0 014.75 4H5V2.75A.75.75 0 015.75 2zm-1 5.5c-.69 0-1.25.56-1.25 1.25v6.5c0 .69.56 1.25 1.25 1.25h10.5c.69 0 1.25-.56 1.25-1.25v-6.5c0-.69-.56-1.25-1.25-1.25H4.75z"
clip-rule="evenodd"
/>
</svg>
</span>{title}
</h3>
<button
on:click={close}
aria-label="Close"
type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-full text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<svg
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clip-rule="evenodd"
></path>
</svg>
</button>
<Icon.Guide size={20} />
</span>
<span>{title}</span>
</div>
<div class="overflow-y-auto overflow-x-hidden w-full">
<div class="p-6">
<div class="dark:border-gray-700 rounded-md border border-gray-200">
{#each guides as guide}
<GuideItem {guide} />
{/each}
</div>
<div slot="headerRight">
<CloseButton onClick={() => close()} />
</div>
<div slot="body" class="p-2 sm:p-5 w-full">
<div class="dark:border-gray-700 rounded-md border border-gray-200">
{#each guides.all() as guide}
<GuideItem {guide} />
{/each}
</div>
</div>
</div>
</div>
</Card>
</Popup>

View file

@ -1,142 +1,77 @@
<script>
import dayjs from 'dayjs'
<script lang="ts">
import type { HTMLPreviewField } from '~/types/htmlPreviewField'
export let data
export let close = () => {}
const fieldset = [
{ name: 'logo', type: 'image', value: data.logo, alt: `${data.name} logo`, title: data.logo },
{ name: 'id', type: 'string', value: data.id },
{ name: 'name', type: 'string', value: data.name },
{ name: 'alt_names', type: 'string', value: data.alt_names.join(', ') },
{
name: 'network',
type: 'link',
value: data.network ? { label: data.network, query: `network:${norm(data.network)}` } : null
},
{
name: 'owners',
type: 'link[]',
value: data.owners.map(value => ({ label: value, query: `owner:${norm(value)}` }))
},
{
name: 'country',
type: 'link',
value: { label: data._country.name, query: `country:${data._country.code}` }
},
{
name: 'subdivision',
type: 'link',
value: data._subdivision
? { label: data._subdivision.name, query: `subdivision:${data._subdivision.code}` }
: null
},
{
name: 'city',
type: 'link',
value: data.city ? { label: data.city, query: `city:${norm(data.city)}` } : null
},
{
name: 'broadcast_area',
type: 'link[]',
value: data._broadcastArea.map(v => ({
label: v.name,
query: `broadcast_area:${v.type}/${v.code}`
}))
},
{
name: 'languages',
type: 'link[]',
value: data._languages.map(v => ({ label: v.name, query: `language:${v.code}` }))
},
{
name: 'categories',
type: 'link[]',
value: data._categories.map(v => ({ label: v.name, query: `category:${v.id}` }))
},
{
name: 'is_nsfw',
type: 'link',
value: { label: data.is_nsfw.toString(), query: `is_nsfw:${data.is_nsfw.toString()}` }
},
{
name: 'launched',
type: 'date',
value: data.launched ? dayjs(data.launched).format('D MMMM YYYY') : null
},
{
name: 'closed',
type: 'date',
value: data.closed ? dayjs(data.closed).format('D MMMM YYYY') : null
},
{
name: 'replaced_by',
type: 'link',
value: data.replaced_by ? { label: data.replaced_by, query: `id:${data.replaced_by}` } : null
},
{ name: 'website', type: 'external_link', value: data.website }
].filter(f => (Array.isArray(f.value) ? f.value.length : f.value))
function norm(value) {
value = value.includes(' ') ? `"${value}"` : value
return encodeURIComponent(value)
}
export let fieldset: HTMLPreviewField[] = []
export let onClick = () => {}
</script>
<table class="table-fixed w-full">
<tbody>
{#each fieldset as field}
<tr class="overflow-hidden">
<td class="align-top w-[140px] sm:w-[180px]">
<tr>
<td class="align-top w-[140px] sm:w-[200px]">
<div class="flex pr-5 pb-3 text-sm text-gray-500 whitespace-nowrap dark:text-gray-400">
{field.name}
</div>
</td>
<td class="align-top w-full overflow-hidden">
<div class="pb-3 text-sm text-gray-800 dark:text-gray-100">
<div class="pb-3 text-sm text-gray-900 dark:text-gray-100">
{#if field.type === 'image'}
<img
src={field.value}
alt={field.alt}
title={field.title}
src={field.value.src}
alt={field.value.alt}
title={field.value.title}
referrerpolicy="no-referrer"
class="border rounded-sm overflow-hidden border-gray-200 bg-[#e6e6e6]"
/>
{:else if field.type === 'link'}
<a
href="/?q={field.value.query}"
on:click={() => close()}
class="underline hover:text-blue-500"
title={field.value.label}
>
{field.value.label}
</a>
{:else if field.type === 'link[]'}
{#each field.value as value, i}
{#if i > 0}<span>,&nbsp; </span>
{/if}
<div class="truncate">
<a
href="/?q={value.query}"
on:click={() => close()}
class="underline hover:text-blue-500"
title={value.label}
href="/?q={field.value.query}"
onclick={onClick}
class="underline hover:text-blue-400"
title={field.value.label}
>
{value.label}
{field.value.label}
</a>
{/each}
</div>
{:else if field.type === 'link[]'}
<div class="overflow-hidden text-ellipsis">
{#each field.value as value, i}
{#if i > 0}<span>,&nbsp; </span>
{/if}
<a
href="/?q={value.query}"
onclick={onClick}
class="underline hover:text-blue-400"
title={value.label}
>
{value.label}
</a>
{/each}
</div>
{:else if field.type === 'external_link'}
<a
href={field.value}
class="underline hover:text-blue-500 truncate"
target="_blank"
rel="noopener noreferrer"
title={field.value}>{field.value}</a
>
<div class="truncate">
<a
href={field.value.href}
class="underline hover:text-blue-400"
target="_blank"
rel="noopener noreferrer"
title={field.value.title}>{field.value.label}</a
>
</div>
{:else if field.name === 'id'}
<span class="truncate" title={field.value}>{field.value}</span>
{:else}
<span title={field.value}>{field.value}</span>
<span class="break-all" title={field.value.toString()}>{field.value}</span>
{:else if field.type === 'string[]'}
<div class="overflow-hidden text-ellipsis">
{#each field.value as value, i}
{#if i > 0}<span>,&nbsp; </span>
{/if}
<span title={value.toString()}>{value}</span>
{/each}
</div>
{:else if field.type === 'string'}
<span title={field.title}>{field.value}</span>
{/if}
</div>
</td>

View file

@ -0,0 +1,20 @@
<script>
export let onClick = () => {}
export let variant = 'default'
export let size = 40
let className = 'rounded-lg text-sm flex items-center justify-center cursor-pointer shrink-0'
if (variant === 'light') className += ' hover:bg-primary-810 text-gray-300'
else className += ' hover:bg-gray-100 dark:hover:bg-primary-750 text-gray-400'
</script>
<button
type="button"
class={className}
style:width={`${size}px`}
style:height={`${size}px`}
onclick={onClick}
{...$$restProps}
>
<slot />
</button>

View file

@ -1,32 +1,31 @@
<script>
import { onMount } from 'svelte'
<script lang="ts">
import type { JsonDataViewerField } from '~/types/jsonDataViewerField'
import { JsonView } from '@zerodevx/svelte-json-view'
export let data
let dark = false
let fieldset = []
for (let key in data) {
if (key.startsWith('_')) continue
fieldset.push({
name: key,
value: data[key]
})
}
onMount(() => {
if (
localStorage.getItem('color-theme') === 'light' ||
(!('color-theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
dark = false
} else {
dark = true
}
})
export let fieldset: JsonDataViewerField[] = []
</script>
<table class="table-fixed w-full dark:text-white">
<tbody>
{#each fieldset as field}
<tr>
<td
class="w-[7rem] md:w-[11rem] px-4 py-1 text-sm text-gray-400 whitespace-nowrap dark:text-gray-400 align-top"
>
{field.name}
</td>
<td class="px-4 py-1 text-sm text-gray-600 dark:text-gray-100 align-top value break-words">
{#if Array.isArray(field.value) && field.value.length}
<JsonView json={field.value} />
{:else}
<code>{JSON.stringify(field.value)}</code>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<style>
:global(.value .val),
:global(.value .key) {
@ -59,24 +58,3 @@
color: #9ca3b0;
}
</style>
<table class="table-fixed w-full dark:text-white">
<tbody>
{#each fieldset as field}
<tr>
<td
class="w-[7rem] md:w-[11rem] px-4 py-1 text-sm text-gray-400 whitespace-nowrap dark:text-gray-400 align-top"
>
{field.name}
</td>
<td class="px-4 py-1 text-sm text-gray-600 dark:text-gray-100 align-top value break-words">
{#if Array.isArray(field.value) && field.value.length}
<JsonView json="{field.value}" />
{:else}
<code>{JSON.stringify(field.value)}</code>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>

View file

@ -0,0 +1,3 @@
<div class="flex space-x-2 w-29 text-primary-900 dark:text-primary-100 items-center">
<div class="font-mono font-semibold leading-4">/iptv_org</div>
</div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import IconButton from '~/components/IconButton.svelte'
import { clickOutside } from '~/actions'
import * as Icon from '~/icons'
export let isOpened = false
function toggleMenu() {
isOpened = !isOpened
}
function closeMenu() {
isOpened = false
}
</script>
<div class="relative" use:clickOutside on:outside={closeMenu}>
<IconButton onClick={toggleMenu} aria-label="Menu">
<Icon.Menu size={16} />
</IconButton>
{#if isOpened}
<div
class="rounded-md bg-white dark:bg-primary-810 absolute top-11 right-0 w-48 z-10 p-1 border border-gray-200 dark:border-primary-750"
>
<slot />
</div>
{/if}
</div>

View file

@ -1,76 +1,120 @@
<script>
import { query, hasQuery, search } from '~/store'
import SearchButton from './SearchButton.svelte'
import SearchFieldMini from './SearchFieldMini.svelte'
import Divider from './Divider.svelte'
import CreatePlaylistButton from './CreatePlaylistButton.svelte'
import ToggleModeButton from './ToggleModeButton.svelte'
import GitHubButton from './GitHubButton.svelte'
import { setSearchParam } from '~/utils'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import {
CreatePlaylistButton,
ToggleModeButton,
GitHubButton,
SearchButton,
SearchField,
Logo
} from '~/components'
export let withSearch = false
export let version = 'default'
export let onSearchButtonClick = () => {}
function reset() {
document.body.scrollIntoView()
query.set('')
hasQuery.set(false)
search('')
}
let scrollY = 0
let input
function scrollToTop() {
document.body.scrollIntoView()
}
function reset() {
scrollToTop()
query.set('')
setSearchParam('q', '')
hasQuery.set(false)
isSearching.set(true)
setTimeout(() => {
search('')
}, 0)
}
function focusOnInput() {
if (input) input.focus()
}
</script>
<nav
class="bg-white border-b border-gray-200 py-2.5 dark:border-gray-700 dark:bg-gray-800 w-full h-[61px]"
>
<div class="flex justify-between items-center mx-auto px-3 w-full max-w-6xl">
<div class="flex flex-start items-center sm:basis-88 shrink">
<a
href="/"
on:click={() => {
reset()
}}
class="flex mr-6"
>
<span
class="text-[1.15rem] text-[#24292f] self-center font-semibold whitespace-nowrap dark:text-white font-mono"
>/iptv-org</span
>
</a>
<div class="hidden sm:block w-full">
{#if withSearch}
<SearchFieldMini />
{/if}
</div>
</div>
<svelte:window bind:scrollY />
<div class="flex flex-end items-center space-x-4 pl-3">
<div class="inline-flex space-x-2">
{#if withSearch}
{#if version === 'default'}
<nav
class="py-2.5 w-full h-[61px] bg-[#f8fafc] dark:bg-primary-850 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700"
class:border-b={scrollY > 0}
>
<div class="flex justify-between items-center mx-auto px-3 w-full max-w-7xl">
<div class="flex flex-start items-center sm:basis-120 shrink">
<a href="/" class="pr-2" onclick={reset}>
<Logo />
</a>
<div class="hidden sm:block w-full">
{#if scrollY > 150}
<SearchField
version="mini"
bind:this={input}
onClear={() => {
query.set('')
focusOnInput()
}}
onSubmit={() => {
goto(`/?q=${$query}`)
}}
/>
{/if}
</div>
</div>
<div class="inline-flex sm:space-x-1">
{#if scrollY > 150}
<div class="block sm:hidden">
<SearchButton
on:click={() => {
onClick={() => {
scrollToTop()
onSearchButtonClick()
}}
/>
</div>
{/if}
<CreatePlaylistButton
on:click={() => {
if ($page.url.pathname !== '/') {
goto('/')
}
}}
/>
</div>
<Divider />
<div class="inline-flex space-x-2">
<CreatePlaylistButton />
<ToggleModeButton />
<GitHubButton />
</div>
</div>
</div>
</nav>
</nav>
{:else if version === 'channelPage'}
<nav
class="py-2.5 w-full h-[61px] bg-[#f8fafc] dark:bg-primary-850 text-gray-500 dark:text-gray-400 border-gray-200 dark:border-gray-700"
class:border-b={scrollY > 0}
>
<div class="flex justify-between items-center mx-auto px-3 w-full max-w-7xl">
<div class="flex flex-start items-center sm:basis-120 shrink">
<a href="/" class="pr-2" onclick={reset}>
<Logo />
</a>
<div class="hidden sm:block w-full">
<SearchField
version="mini"
onClear={reset}
onSubmit={() => {
goto(`/?q=${$query}`)
}}
/>
</div>
</div>
<div class="inline-flex sm:space-x-1">
<div class="block sm:hidden">
<SearchButton
onClick={() => {
goto('/')
}}
/>
</div>
<ToggleModeButton />
<GitHubButton />
</div>
</div>
</nav>
{/if}

View file

@ -1,7 +0,0 @@
<button
{...$$restProps}
class="rounded-md bg-transparent transition-colors duration-200 border border-gray-200 hover:border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-100 dark:hover:bg-gray-700 dark:hover:border-gray-700 text-sm font-normal text-center px-4 h-10 flex items-center justify-center space-x-3"
on:click
>
<slot />
</button>

View file

@ -0,0 +1,8 @@
<script lang="ts">
export let wrapperClass = 'flex justify-center p-2 pb-20 sm:py-28 z-50'
export let onClose = () => {}
</script>
<div class={wrapperClass} role="presentation" on:keypress on:click|self={() => onClose()}>
<div class="w-full max-w-3xl"><slot /></div>
</div>

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { Collection } from '@freearhey/core/browser'
import IconButton from './IconButton.svelte'
import { selected } from '~/store'
import * as Icon from '~/icons'
let isAnySelected = true
selected.subscribe((_selected: Collection) => {
isAnySelected = _selected.notEmpty()
})
function reset() {
selected.set(new Collection())
}
</script>
{#if isAnySelected}
<IconButton onClick={reset} aria-label="Reset" title="Reset" variant="light">
<Icon.Reset size={24} />
</IconButton>
{/if}

View file

@ -1,24 +1,12 @@
<script>
import SquareButton from '~/components/SquareButton.svelte'
import { createEventDispatcher } from 'svelte'
import IconButton from '~/components/IconButton.svelte'
import * as Icon from '~/icons'
const dispatch = createEventDispatcher()
export let onClick
</script>
<SquareButton
on:click={() => {
dispatch('click')
}}
aria-label="Go to search"
title="Go to search"
>
<IconButton {onClick} aria-label="Go to search" title="Go to search">
<span class="inline">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
></path>
</svg>
<Icon.Search size={20} />
</span>
</SquareButton>
</IconButton>

View file

@ -1,68 +1,70 @@
<script>
import { getContext } from 'svelte'
import { query, search, setSearchParam } from '~/store'
import SearchSyntaxPopup from './SearchSyntaxPopup.svelte'
<script lang="ts">
import { query, isSearching } from '~/store'
import * as Icon from '~/icons'
const { open } = getContext('simple-modal')
export let version = 'default'
export let onClear = () => {}
export let onSubmit = () => {}
export let found = 0
export let isLoading = true
let input: HTMLElement
function onSubmit() {
setSearchParam('q', $query)
search($query)
export function blur() {
if (input) input.blur()
}
const showSearchSyntax = () => {
open(
SearchSyntaxPopup,
{},
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
export function focus() {
if (input) input.focus()
}
</script>
<form class="mb-5" on:submit|preventDefault={onSubmit}>
<div>
<label for="search-input" class="sr-only">Search</label>
<div class="relative mt-1">
<div
class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none text-gray-500 dark:text-gray-400"
>
<svg
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
></path>
</svg>
</div>
<input
type="search"
id="search-input"
bind:value={$query}
class="bg-white border border-gray-300 text-gray-900 outline-blue-500 text-sm rounded-md block w-full pl-10 p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Search for channels"
/>
<form
onsubmit={event => {
event.preventDefault()
blur()
onSubmit()
}}
autocomplete="off"
class:w-full={version === 'mini'}
>
<label for="search-input" class="sr-only">Search</label>
<div class="relative" class:w-full={version === 'mini'}>
<div
class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none text-gray-500 dark:text-gray-400"
>
{#if $isSearching}
<Icon.Spinner size={20} />
{:else}
<Icon.Search size={20} />
{/if}
</div>
<div class="mt-2 flex justify-between px-1">
<span class="inline-flex text-sm text-gray-500 dark:text-gray-400 font-mono pt-[2px]"
>Found&nbsp;
<span class:animate-spin={isLoading}>{!isLoading ? found.toLocaleString() : '/'}</span>
&nbsp;channel(s)</span
>
<button
type="button"
on:click|preventDefault={showSearchSyntax}
class="inline-flex text-sm text-gray-500 dark:text-gray-400 font-mono hover:underline hover:text-blue-500 dark:hover:text-blue-400 pt-[2px]"
>
Search syntax
</button>
<input
type="search"
id="search-input"
bind:this={input}
bind:value={$query}
class:h-10.5={version === 'default'}
class:h-9.5={version === 'mini'}
class="bg-white border border-gray-300 text-gray-900 outline-blue-500 text-sm rounded-md block w-full pl-10 py-2 px-1.5 dark:bg-primary-750 dark:border-primary-700 dark:placeholder-gray-400 dark:text-white placeholder-gray-400"
placeholder="Search"
/>
<div
class="absolute right-0 top-0 pr-1 text-gray-400 flex items-center"
class:h-10.5={version === 'default'}
class:h-9.5={version === 'mini'}
>
{#if $query.length}
<button
type="reset"
onmousedown={event => {
event.preventDefault()
onClear()
}}
class="cursor-pointer w-6 h-6"
title="Clear"
>
<Icon.Clear size={16} />
</button>
{/if}
</div>
</div>
</form>

View file

@ -1,39 +0,0 @@
<script>
import { query } from '~/store'
import { goto } from '$app/navigation'
function onSubmit() {
goto(`/?q=${$query}`)
}
</script>
<form on:submit|preventDefault={onSubmit} autocomplete="off" class="w-full">
<div class="w-full">
<label for="search-input" class="sr-only">Search</label>
<div class="relative w-full">
<div
class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none text-gray-500 dark:text-gray-400"
>
<svg
class="w-5 h-5"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z"
clip-rule="evenodd"
></path>
</svg>
</div>
<input
type="search"
id="search-input"
bind:value={$query}
class="bg-gray-50 border border-gray-300 text-gray-900 outline-blue-500 text-sm rounded-md block w-full pl-10 p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Search"
/>
</div>
</div>
</form>

View file

@ -1,12 +1,11 @@
<script>
import CloseButton from '~/components/CloseButton.svelte'
<script lang="ts">
import { Popup, CloseButton, CodeBlock, Card } from '~/components'
import type { Context } from 'svelte-simple-modal'
import { getContext } from 'svelte'
export let title = 'Search syntax'
const { close } = getContext<Context>('simple-modal')
const { close } = getContext('simple-modal')
let examples = [
const examples = [
{ query: 'cat', result: 'Finds channels that have "cat" in their descriptions.' },
{ query: 'cat dog', result: 'Finds channels that have "cat" AND "dog" in their descriptions.' },
{ query: 'cat,dog', result: 'Finds channels that have "cat" OR "dog" in their descriptions.' },
@ -30,8 +29,13 @@
},
{ query: 'city:"San Francisco"', result: 'Finds all channels broadcast from San Francisco.' },
{ query: 'broadcast_area:c/CV', result: 'Finds channels that are broadcast in Cape Verde.' },
{
query: 'timezone:Asia/Kabul',
result: 'Find channels that are broadcast in the time zone Asia/Kabul.'
},
{ query: 'language:fra', result: 'Find channels that are broadcast in French.' },
{ query: 'category:news', result: 'Finds all the news channels.' },
{ query: 'video_format:1080p', result: 'Find channels that are broadcast in 1080p.' },
{ query: 'website:.', result: 'Finds channels that have a link to the official website.' },
{ query: 'is_nsfw:true', result: 'Finds channels marked as NSFW.' },
{
@ -43,51 +47,32 @@
result:
'Finds channels that have been added to our blocklist due to the claim of the copyright holder.'
},
{ query: 'feeds:>1', result: 'Finds channels with more than 1 feed.' },
{ query: 'streams:<2', result: 'Finds channels with less than 2 streams.' },
{ query: 'guides:>0', result: 'Finds channels that have guides.' }
]
</script>
<div
class="relative px-2 py-20 flex justify-center"
role="presentation"
on:keypress
on:click|self={close}
>
<div class="relative bg-white rounded-md shadow dark:bg-gray-800 w-full max-w-2xl">
<div
class="flex justify-between items-center py-3 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
<Popup onClose={close}>
<Card
><div
slot="headerLeft"
class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center"
>
<h3 class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center">
{title}
</h3>
<CloseButton on:click={close} />
Search syntax
</div>
<div class="overflow-y-auto overflow-x-scroll w-full scrollbar-hide">
<div class="text-gray-800 dark:text-white p-6 inline-block">
<table>
<thead>
<tr>
<th class="border p-2 dark:border-gray-700 font-semibold">Query</th>
<th class="border p-2 dark:border-gray-700 font-semibold">Result</th>
</tr>
</thead>
<tbody class="text-left">
{#each examples as example}
<tr class="even:bg-gray-50 even:dark:bg-gray-700">
<td class="border dark:border-gray-700 px-3 py-3 whitespace-nowrap min-w-[220px]">
<code
class="break-words text-sm text-gray-600 bg-gray-100 dark:text-gray-300 dark:bg-gray-700 px-2 py-1 rounded-sm select-all cursor-text font-mono"
>{example.query}</code
>
</td>
<td class="border dark:border-gray-700 px-4 py-3 min-w-[260px]">{example.result}</td
>
</tr>
{/each}
</tbody>
</table>
</div>
<div slot="headerRight">
<CloseButton onClick={() => close()} />
</div>
</div>
</div>
<div slot="body" class="text-gray-800 dark:text-white pt-2.5 w-full">
{#each examples as example}
<div
class="border-t border-gray-200 dark:border-gray-700 py-5 w-full flex flex-col items-start gap-2 px-5"
>
<CodeBlock>{example.query}</CodeBlock>
<div class="px-1">{example.result}</div>
</div>
{/each}
</div></Card
>
</Popup>

View file

@ -1,58 +1,101 @@
<script>
import OutlineButton from '~/components/OutlineButton.svelte'
import { selected, filteredChannels, channels } from '~/store'
<script lang="ts">
import { selected, channels, hasQuery, searchResults } from '~/store'
import { Collection } from '@freearhey/core/browser'
import IconButton from './IconButton.svelte'
import { Channel } from '~/models'
import * as Icon from '~/icons'
$: hasStreams = $channels.filter(c => c.streams > 0)
$: isAllSelected = $selected.length === hasStreams.length
const channelsWithStreams: Collection = $channels.filter((channel: Channel) =>
channel.hasStreams()
)
let filteredChannelsWithStreams: Collection = channelsWithStreams
let filteredChannels: Collection = $channels
let isLoading = false
let isAllSelected = false
searchResults.subscribe((_searchResults: Collection) => {
onSearchResultsChange(_searchResults)
})
function onSearchResultsChange(_searchResults: Collection) {
if ($hasQuery) {
if (_searchResults.isEmpty()) {
filteredChannels = new Collection()
} else {
filteredChannels = $channels.intersectsBy(_searchResults, (channel: Channel) => channel.id)
}
} else {
filteredChannels = $channels
}
filteredChannelsWithStreams = filteredChannels.filter((channel: Channel) =>
channel.hasStreams()
)
updateState()
}
selected.subscribe((_selected: Collection) => {
updateState()
})
function updateState() {
let _selected = $selected
isAllSelected = true
filteredChannelsWithStreams.forEach((channel: Channel) => {
const isChannelSelected = _selected.includes(
(selectedChannel: Channel) => selectedChannel.id === channel.id
)
if (!isChannelSelected) {
isAllSelected = false
return
}
})
}
function selectAll() {
selected.set(hasStreams)
isLoading = true
setTimeout(() => {
let _selected = $selected
filteredChannelsWithStreams.forEach((channel: Channel) => {
const isChannelSelected = _selected.includes(
(selectedChannel: Channel) => selectedChannel.id === channel.id
)
if (!isChannelSelected) {
_selected.add(channel)
}
})
selected.set(_selected)
isLoading = false
}, 0)
}
function deselectAll() {
selected.set([])
isLoading = true
setTimeout(() => {
let _selected = $selected
filteredChannelsWithStreams.forEach((channel: Channel) => {
_selected.remove((selectedChannel: Channel) => selectedChannel.id === channel.id)
})
selected.set(_selected)
isLoading = false
}, 0)
}
</script>
{#if isAllSelected}
<OutlineButton on:click={deselectAll} aria-label="Deselect All">
<span class="text-gray-500 dark:text-white">
<svg
fill="currentColor"
class="w-5 h-5"
clip-rule="evenodd"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><path
d="m17.5 11c2.484 0 4.5 2.016 4.5 4.5s-2.016 4.5-4.5 4.5-4.5-2.016-4.5-4.5 2.016-4.5 4.5-4.5zm-5.979 5c.043.522.153 1.025.321 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm7.979-1c-.592 0-3.408 0-4 0-.265 0-.5.235-.5.5s.235.5.5.5h4c.265 0 .5-.235.5-.5s-.235-.5-.5-.5zm-6.873-3c-.328.456-.594.96-.785 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm7.373-3.25c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75zm0-4c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75z"
fill-rule="nonzero"
/></svg
>
</span>
<span class="hidden md:inline">Deselect All</span>
</OutlineButton>
{#if isLoading}
<div class="h-10 w-10 flex items-center justify-center text-gray-100">
<Icon.Spinner size={21} />
</div>
{:else if isAllSelected}
<IconButton onClick={deselectAll} aria-label="Deselect All" title="Deselect All" variant="light">
<Icon.DeselectAll size={24} />
</IconButton>
{:else}
<OutlineButton on:click={selectAll} aria-label="Select All">
<span class="text-gray-500 dark:text-white">
<svg
fill="currentColor"
class="w-5 h-5"
clip-rule="evenodd"
fill-rule="evenodd"
stroke-linejoin="round"
stroke-miterlimit="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
><path
d="m17.5 11c2.484 0 4.5 2.016 4.5 4.5s-2.016 4.5-4.5 4.5-4.5-2.016-4.5-4.5 2.016-4.5 4.5-4.5zm-5.979 5c.043.522.153 1.025.321 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm3.704-.024 1.442 1.285c.095.085.215.127.333.127.136 0 .271-.055.37-.162l2.441-2.669c.088-.096.131-.217.131-.336 0-.274-.221-.499-.5-.499-.136 0-.271.055-.37.162l-2.108 2.304-1.073-.956c-.096-.085-.214-.127-.333-.127-.277 0-.5.224-.5.499 0 .137.056.273.167.372zm-2.598-3.976c-.328.456-.594.96-.785 1.5h-9.092c-.414 0-.75-.336-.75-.75s.336-.75.75-.75zm7.373-3.25c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75zm0-4c0-.414-.336-.75-.75-.75h-16.5c-.414 0-.75.336-.75.75s.336.75.75.75h16.5c.414 0 .75-.336.75-.75z"
fill-rule="nonzero"
/></svg
>
</span>
<span class="hidden md:inline">Select All</span>
</OutlineButton>
<IconButton onClick={selectAll} aria-label="Select All" title="Select All" variant="light">
<Icon.SelectAll size={24} />
</IconButton>
{/if}

View file

@ -0,0 +1,24 @@
<script lang="ts">
import IconButton from '~/components/IconButton.svelte'
import type { Channel } from '~/models'
import * as Icon from '~/icons'
export let channel: Channel
async function onClick() {
if (navigator.canShare) {
try {
navigator.share({
title: channel.getUniqueName(),
url: channel.getPageUrl()
})
} catch (err) {
console.log(err.message)
}
}
}
</script>
<IconButton {onClick}>
<Icon.Share class="text-gray-400" size={18} />
</IconButton>

View file

@ -1,8 +0,0 @@
<button
{...$$restProps}
type="button"
on:click
class="rounded-lg text-sm h-10 w-10 flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<slot />
</button>

View file

@ -1,56 +1,46 @@
<script>
import CopyToClipboard from './CopyToClipboard.svelte'
import ExpandButton from './ExpandButton.svelte'
import JsonDataViewer from './JsonDataViewer.svelte'
<script lang="ts">
import { CopyToClipboard, ExpandButton, JsonDataViewer } from '~/components'
import { Stream } from '~/models'
import * as Icon from '~/icons'
export let stream
export let stream: Stream
let expanded = false
let isExpanded = false
</script>
<div
class="w-full bg-gray-100 dark:bg-gray-700 dark:border-gray-600 rounded-md border border-gray-200"
class="w-full bg-gray-100 dark:bg-primary-750 dark:border-gray-600 rounded-md border border-gray-200"
>
<div
class="w-full inline-flex justify-between px-3 py-2 border-gray-200 dark:border-gray-600"
class:border-b={expanded}
class="w-full inline-flex justify-between pl-2 pr-3 py-2 border-gray-200 dark:border-gray-600"
class:border-b={isExpanded}
>
<div class="flex space-x-3 items-center max-w-[90%] w-full">
<ExpandButton on:click={event => (expanded = event.detail.state)} />
<a
class="whitespace-nowrap text-sm text-gray-600 dark:text-gray-100 hover:text-blue-500 hover:underline inline-flex align-middle max-w-[80%] w-full"
href={stream.url}
title={stream.url}
target="_blank"
rel="noopener noreferrer"
>
<span class="truncate">{stream.url}</span><span
class="inline-flex items-center pl-1 text-sm font-semibold text-gray-500 rounded-full"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
<div class="flex space-x-2 items-center w-full">
<ExpandButton bind:expanded={isExpanded} />
<div class="flex w-full items-center space-x-1 overflow-hidden">
<div class="truncate text-gray-600 dark:text-gray-100">
<a
class="whitespace-nowrap text-sm hover:text-blue-500 dark:hover:text-blue-400 hover:underline"
href={stream.url}
title={stream.url}
target="_blank"
rel="noopener noreferrer"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
></path>
</svg>
</span></a
>
</div>
<div class="flex shrink-0">
<CopyToClipboard text={stream.url} />
{stream.url}</a
>
</div>
<div class="text-sm text-gray-400 dark:text-gray-500">
<Icon.ExternalLink size={17} />
</div>
</div>
<div class="flex w-8 justify-end shrink-0">
<CopyToClipboard text={stream.url} />
</div>
</div>
</div>
{#if expanded}
{#if isExpanded}
<div class="w-full flex px-2 py-4">
<JsonDataViewer data={stream} />
<JsonDataViewer fieldset={stream.getFieldset()} />
</div>
{/if}
</div>

View file

@ -1,52 +1,35 @@
<script>
import CloseButton from '~/components/CloseButton.svelte'
import StreamItem from '~/components/StreamItem.svelte'
<script lang="ts">
import { CloseButton, StreamItem, Popup, Card } from '~/components'
import { Collection } from '@freearhey/core/browser'
import type { Context } from 'svelte-simple-modal'
import { getContext } from 'svelte'
import * as Icon from '~/icons'
export let streams = []
export let streams: Collection = new Collection()
export let title = 'Streams'
const { close } = getContext('simple-modal')
const { close } = getContext<Context>('simple-modal')
</script>
<div
class="relative px-2 py-32 flex justify-center"
role="presentation"
on:keypress
on:click|self={close}
>
<div class="relative bg-white rounded-md shadow dark:bg-gray-800 w-full max-w-2xl">
<Popup onClose={() => close()} wrapperClass="flex justify-center p-2 pt-16 sm:py-44 z-50">
<Card>
<div
class="flex justify-between items-center py-3 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
slot="headerLeft"
class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center"
>
<h3 class="text-l font-medium text-gray-800 dark:text-white inline-flex items-center">
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.636 18.364a9 9 0 010-12.728m12.728 0a9 9 0 010 12.728m-9.9-2.829a5 5 0 010-7.07m7.072 0a5 5 0 010 7.07M13 12a1 1 0 11-2 0 1 1 0 012 0z"
/>
</svg>
</span>{title}
</h3>
<CloseButton on:click={close} />
<span
class="inline-flex items-center pr-2 text-sm font-semibold text-gray-500 dark:text-gray-100 rounded-full"
>
<Icon.Stream size={21} />
</span>{title}
</div>
<div class="overflow-y-auto overflow-x-hidden w-full">
<div class="p-6 space-y-2">
{#each streams as stream}
<StreamItem {stream} />
{/each}
</div>
<div slot="headerRight">
<CloseButton onClick={() => close()} />
</div>
</div>
</div>
<div slot="body" class="flex flex-col gap-2 p-2 sm:p-5">
{#each streams.all() as stream, index (stream.getUUID())}
<StreamItem {stream} />
{/each}
</div>
</Card>
</Popup>

View file

@ -1,6 +1,7 @@
<script>
import IconButton from './IconButton.svelte'
import { onMount } from 'svelte'
import SquareButton from './SquareButton.svelte'
import * as Icon from '~/icons'
let dark = false
function toggleDarkMode() {
@ -32,30 +33,10 @@
})
</script>
<SquareButton on:click={toggleDarkMode} aria-label="Toggle Dark Mode">
<svg
class="w-5 h-5"
class:hidden={dark}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
role="img"
>
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<svg
class="w-5 h-5"
class:hidden={!dark}
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
fill-rule="evenodd"
clip-rule="evenodd"
></path>
</svg>
</SquareButton>
<IconButton onClick={toggleDarkMode} aria-label="Toggle Dark Mode">
{#if dark}
<Icon.LightMode size={20} />
{:else}
<Icon.DarkMode size={20} />
{/if}
</IconButton>

45
src/components/index.ts Normal file
View file

@ -0,0 +1,45 @@
export { default as Badge } from './Badge.svelte'
export { default as BlockedBadge } from './BlockedBadge.svelte'
export { default as BottomBar } from './BottomBar.svelte'
export { default as Button } from './Button.svelte'
export { default as Card } from './Card.svelte'
export { default as ChannelEditButton } from './ChannelEditButton.svelte'
export { default as ChannelGrid } from './ChannelGrid.svelte'
export { default as ChannelItem } from './ChannelItem.svelte'
export { default as ChannelPopup } from './ChannelPopup.svelte'
export { default as ChannelRemoveButton } from './ChannelRemoveButton.svelte'
export { default as Checkbox } from './Checkbox.svelte'
export { default as Clipboard } from './Clipboard.svelte'
export { default as CloseButton } from './CloseButton.svelte'
export { default as ClosedBadge } from './ClosedBadge.svelte'
export { default as CodeBlock } from './CodeBlock.svelte'
export { default as CopyLinkButton } from './CopyLinkButton.svelte'
export { default as CopyToClipboard } from './CopyToClipboard.svelte'
export { default as CountryItem } from './CountryItem.svelte'
export { default as CreatePlaylistButton } from './CreatePlaylistButton.svelte'
export { default as DownloadButton } from './DownloadButton.svelte'
export { default as ExpandButton } from './ExpandButton.svelte'
export { default as FeedAddButton } from './FeedAddButton.svelte'
export { default as FeedEditButton } from './FeedEditButton.svelte'
export { default as FeedItem } from './FeedItem.svelte'
export { default as FeedPopup } from './FeedPopup.svelte'
export { default as FeedRemoveButton } from './FeedRemoveButton.svelte'
export { default as GitHubButton } from './GitHubButton.svelte'
export { default as GuideItem } from './GuideItem.svelte'
export { default as GuidesPopup } from './GuidesPopup.svelte'
export { default as HTMLPreview } from './HTMLPreview.svelte'
export { default as IconButton } from './IconButton.svelte'
export { default as JsonDataViewer } from './JsonDataViewer.svelte'
export { default as Logo } from './Logo.svelte'
export { default as Menu } from './Menu.svelte'
export { default as NavBar } from './NavBar.svelte'
export { default as Popup } from './Popup.svelte'
export { default as ResetButton } from './ResetButton.svelte'
export { default as SearchButton } from './SearchButton.svelte'
export { default as SearchField } from './SearchField.svelte'
export { default as SearchSyntaxPopup } from './SearchSyntaxPopup.svelte'
export { default as SelectAllButton } from './SelectAllButton.svelte'
export { default as ShareChannelButton } from './ShareChannelButton.svelte'
export { default as StreamItem } from './StreamItem.svelte'
export { default as StreamsPopup } from './StreamsPopup.svelte'
export { default as ToggleModeButton } from './ToggleModeButton.svelte'