mirror of
https://github.com/iptv-org/iptv-org.github.io.git
synced 2025-05-14 02:50:07 -04:00
Update src/
This commit is contained in:
parent
09b07e9b24
commit
86743c74f5
132 changed files with 4418 additions and 1907 deletions
|
@ -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>
|
5
src/components/Badge.svelte
Normal file
5
src/components/Badge.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
19
src/components/Button.svelte
Normal file
19
src/components/Button.svelte
Normal 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>
|
16
src/components/Card.svelte
Normal file
16
src/components/Card.svelte
Normal 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>
|
29
src/components/ChannelEditButton.svelte
Normal file
29
src/components/ChannelEditButton.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
29
src/components/ChannelRemoveButton.svelte
Normal file
29
src/components/ChannelRemoveButton.svelte
Normal 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>
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
7
src/components/CodeBlock.svelte
Normal file
7
src/components/CodeBlock.svelte
Normal 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>
|
14
src/components/CopyLinkButton.svelte
Normal file
14
src/components/CopyLinkButton.svelte
Normal 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>
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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} {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} {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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -1 +0,0 @@
|
|||
<span class="w-[1px] h-[22px] bg-gray-200 dark:bg-gray-700"></span>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
29
src/components/FeedAddButton.svelte
Normal file
29
src/components/FeedAddButton.svelte
Normal 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>
|
30
src/components/FeedEditButton.svelte
Normal file
30
src/components/FeedEditButton.svelte
Normal 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>
|
109
src/components/FeedItem.svelte
Normal file
109
src/components/FeedItem.svelte
Normal 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>
|
58
src/components/FeedPopup.svelte
Normal file
58
src/components/FeedPopup.svelte
Normal 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>
|
30
src/components/FeedRemoveButton.svelte
Normal file
30
src/components/FeedRemoveButton.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>, </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>, </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>, </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>
|
||||
|
|
20
src/components/IconButton.svelte
Normal file
20
src/components/IconButton.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
3
src/components/Logo.svelte
Normal file
3
src/components/Logo.svelte
Normal 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>
|
29
src/components/Menu.svelte
Normal file
29
src/components/Menu.svelte
Normal 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>
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
8
src/components/Popup.svelte
Normal file
8
src/components/Popup.svelte
Normal 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>
|
22
src/components/ResetButton.svelte
Normal file
22
src/components/ResetButton.svelte
Normal 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}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
<span class:animate-spin={isLoading}>{!isLoading ? found.toLocaleString() : '/'}</span>
|
||||
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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
24
src/components/ShareChannelButton.svelte
Normal file
24
src/components/ShareChannelButton.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
45
src/components/index.ts
Normal 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'
|
Loading…
Add table
Add a link
Reference in a new issue