Update src/
15
src/actions/clickOutside.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
export function clickOutside(node) {
|
||||
const handleClick = event => {
|
||||
if (node && !node.contains(event.target) && !event.defaultPrevented) {
|
||||
node.dispatchEvent(new CustomEvent('outside', node))
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', handleClick, true)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
document.removeEventListener('click', handleClick, true)
|
||||
}
|
||||
}
|
||||
}
|
1
src/actions/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export * from './clickOutside'
|
48
src/app.css
|
@ -1,7 +1,49 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
|
||||
@plugin "tailwind-scrollbar-hide";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme {
|
||||
--color-primary-950: hsl(219, 23%, 5%);
|
||||
--color-primary-900: hsl(219, 23%, 10%);
|
||||
--color-primary-890: hsl(219, 23%, 11%);
|
||||
--color-primary-880: hsl(219, 23%, 12%);
|
||||
--color-primary-870: hsl(219, 23%, 13%);
|
||||
--color-primary-860: hsl(219, 23%, 14%);
|
||||
--color-primary-850: hsl(219, 23%, 15%);
|
||||
--color-primary-840: hsl(219, 23%, 16%);
|
||||
--color-primary-830: hsl(219, 23%, 17%);
|
||||
--color-primary-820: hsl(219, 23%, 18%);
|
||||
--color-primary-810: hsl(219, 23%, 19%);
|
||||
--color-primary-800: hsl(219, 23%, 20%);
|
||||
--color-primary-790: hsl(219, 23%, 21%);
|
||||
--color-primary-780: hsl(219, 23%, 22%);
|
||||
--color-primary-770: hsl(219, 23%, 23%);
|
||||
--color-primary-760: hsl(219, 23%, 24%);
|
||||
--color-primary-750: hsl(219, 23%, 25%);
|
||||
--color-primary-740: hsl(219, 23%, 26%);
|
||||
--color-primary-730: hsl(219, 23%, 27%);
|
||||
--color-primary-720: hsl(219, 23%, 28%);
|
||||
--color-primary-710: hsl(219, 23%, 29%);
|
||||
--color-primary-700: hsl(219, 23%, 30%);
|
||||
--color-primary-650: hsl(219, 23%, 35%);
|
||||
--color-primary-600: hsl(219, 23%, 40%);
|
||||
--color-primary-500: hsl(219, 23%, 50%);
|
||||
--color-primary-400: hsl(219, 23%, 60%);
|
||||
--color-primary-300: hsl(219, 23%, 70%);
|
||||
--color-primary-200: hsl(219, 23%, 80%);
|
||||
--color-primary-100: hsl(219, 23%, 90%);
|
||||
--color-primary-50: hsl(219, 23%, 95%);
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
input[type='search']::-webkit-search-decoration,
|
||||
input[type='search']::-webkit-search-cancel-button,
|
||||
input[type='search']::-webkit-search-results-button,
|
||||
input[type='search']::-webkit-search-results-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
|
52
src/commands/api/load.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { ApiClient, DataLoader, DataProcessor } from '../../core'
|
||||
import { DataStorage } from '../../core/dataStorage'
|
||||
import cliProgress from 'cli-progress'
|
||||
import numeral from 'numeral'
|
||||
|
||||
async function main() {
|
||||
const progressBar = new cliProgress.MultiBar({
|
||||
stopOnComplete: true,
|
||||
hideCursor: true,
|
||||
forceRedraw: true,
|
||||
barsize: 36,
|
||||
format(options, params, payload) {
|
||||
const filename = payload.filename.padEnd(18, ' ')
|
||||
const barsize = options.barsize || 40
|
||||
const percent = (params.progress * 100).toFixed(2)
|
||||
const speed = payload.speed ? numeral(payload.speed).format('0.0 b') + '/s' : 'N/A'
|
||||
const total = numeral(params.total).format('0.0 b')
|
||||
const completeSize = Math.round(params.progress * barsize)
|
||||
const incompleteSize = barsize - completeSize
|
||||
const bar =
|
||||
options.barCompleteString && options.barIncompleteString
|
||||
? options.barCompleteString.substr(0, completeSize) +
|
||||
options.barGlue +
|
||||
options.barIncompleteString.substr(0, incompleteSize)
|
||||
: '-'.repeat(barsize)
|
||||
|
||||
return `${filename} [${bar}] ${percent}% | ETA: ${params.eta}s | ${total} | ${speed}`
|
||||
}
|
||||
})
|
||||
const storage = new DataStorage()
|
||||
const processor = new DataProcessor()
|
||||
const client = new ApiClient()
|
||||
const dataLoader = new DataLoader({ storage, client, processor, progressBar })
|
||||
|
||||
const requests = [
|
||||
dataLoader.download('channels.json'),
|
||||
dataLoader.download('feeds.json'),
|
||||
dataLoader.download('categories.json'),
|
||||
dataLoader.download('countries.json'),
|
||||
dataLoader.download('regions.json'),
|
||||
dataLoader.download('subdivisions.json'),
|
||||
dataLoader.download('timezones.json'),
|
||||
dataLoader.download('languages.json'),
|
||||
dataLoader.download('streams.json'),
|
||||
dataLoader.download('guides.json'),
|
||||
dataLoader.download('blocklist.json')
|
||||
]
|
||||
|
||||
await Promise.all(requests)
|
||||
}
|
||||
|
||||
main()
|
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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'
|
15
src/core/apiClient.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
|
||||
export class ApiClient {
|
||||
instance: AxiosInstance
|
||||
|
||||
constructor() {
|
||||
this.instance = axios.create({
|
||||
baseURL: 'https://iptv-org.github.io/api/'
|
||||
})
|
||||
}
|
||||
|
||||
get(pathname: string, config?: AxiosRequestConfig): Promise<any> {
|
||||
return this.instance.get(pathname, config)
|
||||
}
|
||||
}
|
143
src/core/dataLoader.ts
Normal file
|
@ -0,0 +1,143 @@
|
|||
import { ApiClient } from './apiClient'
|
||||
import { Collection } from '@freearhey/core/browser'
|
||||
import type { DataProcessor } from './dataProcessor'
|
||||
|
||||
export type DataLoaderProps = {
|
||||
client: ApiClient
|
||||
processor: DataProcessor
|
||||
storage?: any
|
||||
progressBar?: any
|
||||
}
|
||||
|
||||
export type DataLoaderData = {
|
||||
channels: Collection
|
||||
countries: Collection
|
||||
regions: Collection
|
||||
languages: Collection
|
||||
subdivisions: Collection
|
||||
categories: Collection
|
||||
streams: Collection
|
||||
guides: Collection
|
||||
blocklistRecords: Collection
|
||||
}
|
||||
|
||||
export class DataLoader {
|
||||
client: ApiClient
|
||||
processor: DataProcessor
|
||||
storage: any
|
||||
progressBar: any
|
||||
|
||||
constructor(props: DataLoaderProps) {
|
||||
this.client = props.client
|
||||
this.storage = props.storage
|
||||
this.progressBar = props.progressBar
|
||||
this.processor = props.processor
|
||||
}
|
||||
|
||||
async load(): Promise<DataLoaderData> {
|
||||
const [
|
||||
countries,
|
||||
regions,
|
||||
subdivisions,
|
||||
languages,
|
||||
categories,
|
||||
streams,
|
||||
blocklist,
|
||||
channels,
|
||||
feeds,
|
||||
timezones,
|
||||
guides
|
||||
] = await Promise.all([
|
||||
this.fetch('countries.json'),
|
||||
this.fetch('regions.json'),
|
||||
this.fetch('subdivisions.json'),
|
||||
this.fetch('languages.json'),
|
||||
this.fetch('categories.json'),
|
||||
this.fetch('streams.json'),
|
||||
this.fetch('blocklist.json'),
|
||||
this.fetch('channels.json'),
|
||||
this.fetch('feeds.json'),
|
||||
this.fetch('timezones.json'),
|
||||
this.fetch('guides.json')
|
||||
])
|
||||
|
||||
return this.processor.process({
|
||||
countries,
|
||||
regions,
|
||||
subdivisions,
|
||||
languages,
|
||||
categories,
|
||||
streams,
|
||||
blocklist,
|
||||
channels,
|
||||
feeds,
|
||||
timezones,
|
||||
guides
|
||||
})
|
||||
}
|
||||
|
||||
async loadFromDisk(): Promise<DataLoaderData> {
|
||||
const [
|
||||
countries,
|
||||
regions,
|
||||
subdivisions,
|
||||
languages,
|
||||
categories,
|
||||
streams,
|
||||
blocklist,
|
||||
channels,
|
||||
feeds,
|
||||
timezones,
|
||||
guides
|
||||
] = await Promise.all([
|
||||
this.storage.load('countries.json'),
|
||||
this.storage.load('regions.json'),
|
||||
this.storage.load('subdivisions.json'),
|
||||
this.storage.load('languages.json'),
|
||||
this.storage.load('categories.json'),
|
||||
this.storage.load('streams.json'),
|
||||
this.storage.load('blocklist.json'),
|
||||
this.storage.load('channels.json'),
|
||||
this.storage.load('feeds.json'),
|
||||
this.storage.load('timezones.json'),
|
||||
this.storage.load('guides.json')
|
||||
])
|
||||
|
||||
return this.processor.process({
|
||||
countries,
|
||||
regions,
|
||||
subdivisions,
|
||||
languages,
|
||||
categories,
|
||||
streams,
|
||||
blocklist,
|
||||
channels,
|
||||
feeds,
|
||||
timezones,
|
||||
guides
|
||||
})
|
||||
}
|
||||
|
||||
async fetch(filename: string): Promise<any[]> {
|
||||
return this.client.get(filename).then(response => response.data)
|
||||
}
|
||||
|
||||
async download(filename: string) {
|
||||
if (!this.storage || !this.progressBar) return
|
||||
|
||||
const stream = await this.storage.createStream(filename)
|
||||
const progressBar = this.progressBar.create(0, 0, { filename })
|
||||
|
||||
this.client
|
||||
.get(filename, {
|
||||
responseType: 'stream',
|
||||
onDownloadProgress({ total, loaded, rate }) {
|
||||
if (total) progressBar.setTotal(total)
|
||||
progressBar.update(loaded, { speed: rate })
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
response.data.pipe(stream)
|
||||
})
|
||||
}
|
||||
}
|
91
src/core/dataProcessor.ts
Normal file
|
@ -0,0 +1,91 @@
|
|||
import { Collection } from '@freearhey/core/browser'
|
||||
import {
|
||||
Country,
|
||||
Language,
|
||||
Subdivision,
|
||||
Region,
|
||||
Category,
|
||||
Stream,
|
||||
Channel,
|
||||
Guide,
|
||||
BlocklistRecord,
|
||||
Feed
|
||||
} from '../models'
|
||||
|
||||
export class DataProcessor {
|
||||
constructor() {}
|
||||
|
||||
process(data) {
|
||||
const categories = new Collection(data.categories).map(data => new Category(data))
|
||||
const categoriesKeyById = categories.keyBy((category: Category) => category.id)
|
||||
|
||||
let countries = new Collection(data.countries).map(data => new Country(data))
|
||||
const countriesKeyByCode = countries.keyBy((country: Country) => country.code)
|
||||
|
||||
const subdivisions = new Collection(data.subdivisions).map(data => new Subdivision(data))
|
||||
const subdivisionsKeyByCode = subdivisions.keyBy((subdivision: Subdivision) => subdivision.code)
|
||||
|
||||
const regions = new Collection(data.regions).map(data => new Region(data))
|
||||
const regionsKeyByCode = regions.keyBy((region: Region) => region.code)
|
||||
|
||||
const blocklistRecords = new Collection(data.blocklist).map(data => new BlocklistRecord(data))
|
||||
const blocklistRecordsGroupedByChannelId = blocklistRecords.groupBy(
|
||||
(blocklistRecord: BlocklistRecord) => blocklistRecord.channelId
|
||||
)
|
||||
|
||||
let streams = new Collection(data.streams).map(data => new Stream(data))
|
||||
const streamsGroupedByStreamId = streams.groupBy((stream: Stream) => stream.getId())
|
||||
|
||||
const guides = new Collection(data.guides).map(data => new Guide(data))
|
||||
const guidesGroupedByStreamId = guides.groupBy((guide: Guide) => guide.getId())
|
||||
|
||||
const languages = new Collection(data.languages).map(data => new Language(data))
|
||||
const languagesKeyByCode = languages.keyBy((language: Language) => language.code)
|
||||
|
||||
let feeds = new Collection(data.feeds).map(data =>
|
||||
new Feed(data)
|
||||
.withStreams(streamsGroupedByStreamId)
|
||||
.withGuides(guidesGroupedByStreamId)
|
||||
.withLanguages(languagesKeyByCode)
|
||||
.withBroadcastArea(countriesKeyByCode, subdivisionsKeyByCode, regionsKeyByCode, regions)
|
||||
)
|
||||
const feedsGroupedByChannelId = feeds.groupBy((feed: Feed) => feed.channelId)
|
||||
const feedsKeyById = feeds.keyBy((feed: Feed) => feed.id)
|
||||
|
||||
let channels = new Collection(data.channels).map(data =>
|
||||
new Channel(data)
|
||||
.withCountry(countriesKeyByCode)
|
||||
.withSubdivision(subdivisionsKeyByCode)
|
||||
.withCategories(categoriesKeyById)
|
||||
.withFeeds(feedsGroupedByChannelId)
|
||||
.withBlocklistRecords(blocklistRecordsGroupedByChannelId)
|
||||
)
|
||||
const channelsGroupedByCountryCode = channels.groupBy((channel: Channel) => channel.countryCode)
|
||||
const channelsKeyById = channels.keyBy((channel: Channel) => channel.id)
|
||||
const channelsGroupedByName = channels.groupBy((channel: Channel) => channel.name)
|
||||
|
||||
channels = channels.map((channel: Channel) => channel.setHasUniqueName(channelsGroupedByName))
|
||||
|
||||
feeds = feeds.map((feed: Feed) => feed.withChannel(channelsKeyById))
|
||||
|
||||
streams = streams.map((stream: Stream) =>
|
||||
stream.withChannel(channelsKeyById).withFeed(feedsKeyById)
|
||||
)
|
||||
|
||||
countries = countries.map((country: Country) =>
|
||||
country.withChannels(channelsGroupedByCountryCode)
|
||||
)
|
||||
|
||||
return {
|
||||
countries,
|
||||
regions,
|
||||
subdivisions,
|
||||
languages,
|
||||
categories,
|
||||
streams,
|
||||
blocklistRecords,
|
||||
channels,
|
||||
guides
|
||||
}
|
||||
}
|
||||
}
|
34
src/core/dataStorage.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import * as path from 'path'
|
||||
import fs from 'fs-extra'
|
||||
|
||||
export class DataStorage {
|
||||
_rootDir: string
|
||||
|
||||
constructor(rootDir?: string) {
|
||||
this._rootDir = 'temp/data'
|
||||
}
|
||||
|
||||
async load(filename: string) {
|
||||
const { default: data } = await import(`../../temp/data/${filename.replace('.json', '')}.json`)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async createDir(dir: string): Promise<void> {
|
||||
const absFilepath = path.isAbsolute(dir) ? path.resolve(dir) : path.join(this._rootDir, dir)
|
||||
if (await fs.exists(absFilepath)) return
|
||||
|
||||
await fs.mkdir(absFilepath, { recursive: true }).catch(console.error)
|
||||
}
|
||||
|
||||
async createStream(filepath: string): Promise<NodeJS.WriteStream> {
|
||||
const absFilepath = path.isAbsolute(filepath)
|
||||
? path.resolve(filepath)
|
||||
: path.join(this._rootDir, filepath)
|
||||
const dir = path.dirname(filepath)
|
||||
|
||||
await this.createDir(dir)
|
||||
|
||||
return fs.createWriteStream(absFilepath) as unknown as NodeJS.WriteStream
|
||||
}
|
||||
}
|
5
src/core/index.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
export * from './apiClient'
|
||||
export * from './dataLoader'
|
||||
export * from './playlistCreator'
|
||||
export * from './searchEngine'
|
||||
export * from './dataProcessor'
|
17
src/core/playlistCreator.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type { Collection } from '@freearhey/core/browser'
|
||||
import { Playlist } from 'iptv-playlist-generator'
|
||||
import { Stream } from '../../src/models'
|
||||
|
||||
export class PlaylistCreator {
|
||||
constructor() {}
|
||||
|
||||
create(streams: Collection): Playlist {
|
||||
const playlist = new Playlist()
|
||||
streams.forEach((stream: Stream) => {
|
||||
const link = stream.getPlaylistLink()
|
||||
playlist.links.push(link)
|
||||
})
|
||||
|
||||
return playlist
|
||||
}
|
||||
}
|
20
src/core/searchEngine.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import sjs from '@freearhey/search-js'
|
||||
import { Collection } from '@freearhey/core/browser'
|
||||
|
||||
export class SearchEngine {
|
||||
searchIndex: any
|
||||
|
||||
constructor() {}
|
||||
|
||||
createIndex(searchable: Collection) {
|
||||
this.searchIndex = sjs.createIndex(searchable.all())
|
||||
}
|
||||
|
||||
search(query: string): Collection {
|
||||
if (!this.searchIndex || !query) return new Collection()
|
||||
|
||||
const results = this.searchIndex.search(query)
|
||||
|
||||
return new Collection(results)
|
||||
}
|
||||
}
|
17
src/icons/Add.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 12h14m-7 7V5"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 313 B |
16
src/icons/Check.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 12.6111L8.92308 17.5L20 6.5"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 307 B |
14
src/icons/CheckboxChecked.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<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>
|
After Width: | Height: | Size: 452 B |
10
src/icons/CheckboxDisabled.svelte
Normal file
|
@ -0,0 +1,10 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
After Width: | Height: | Size: 196 B |
14
src/icons/CheckboxIndeterminate.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<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>
|
After Width: | Height: | Size: 378 B |
11
src/icons/CheckboxUnchecked.svelte
Normal file
|
@ -0,0 +1,11 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="none" />
|
||||
</svg>
|
After Width: | Height: | Size: 232 B |
13
src/icons/Clear.svelte
Normal file
|
@ -0,0 +1,13 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
d="M8,16 C12.4183,16 16,12.4183 16,8 C16,3.58172 12.4183,0 8,0 C3.58172,0 0,3.58172 0,8 C0,12.4183 3.58172,16 8,16 Z M4.29289,4.29289 C4.68342,3.90237 5.31658,3.90237 5.70711,4.29289 L8,6.58579 L10.2929,4.29289 C10.6834,3.90237 11.3166,3.90237 11.7071,4.29289 C12.0976,4.68342 12.0976,5.31658 11.7071,5.70711 L9.41421,8 L11.7071,10.2929 C12.0976,10.6834 12.0976,11.3166 11.7071,11.7071 C11.3166,12.0976 10.6834,12.0976 10.2929,11.7071 L8,9.41421 L5.70711,11.7071 C5.31658,12.0976 4.68342,12.0976 4.29289,11.7071 C3.90237,11.3166 3.90237,10.6834 4.29289,10.2929 L6.58579,8 L4.29289,5.70711 C3.90237,5.31658 3.90237,4.68342 4.29289,4.29289 Z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 845 B |
14
src/icons/Close.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
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>
|
After Width: | Height: | Size: 431 B |
16
src/icons/Copy.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
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>
|
After Width: | Height: | Size: 398 B |
15
src/icons/CreatePlaylist.svelte
Normal file
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 600 600"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M47 157C47 142.088 59.0883 130 74 130H437C451.912 130 464 142.088 464 157C464 171.912 451.912 184 437 184H74C59.0883 184 47 171.912 47 157ZM47 286C47 271.088 59.0883 259 74 259H330C344.912 259 357 271.088 357 286C357 300.912 344.912 313 330 313H74C59.0883 313 47 300.912 47 286ZM74 386C59.0883 386 47 398.088 47 413C47 427.912 59.0883 440 74 440H215C229.912 440 242 427.912 242 413C242 398.088 229.912 386 215 386H74ZM289 414C289 399.088 301.088 387 316 387H392V313C392 298.088 404.088 286 419 286C433.912 286 446 298.088 446 313V387H521C535.912 387 548 399.088 548 414C548 428.912 535.912 441 521 441H446V518C446 532.912 433.912 545 419 545C404.088 545 392 532.912 392 518V441H316C301.088 441 289 428.912 289 414Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 963 B |
13
src/icons/DarkMode.svelte
Normal file
|
@ -0,0 +1,13 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
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>
|
After Width: | Height: | Size: 301 B |
16
src/icons/DeselectAll.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21 12C21 13.1819 20.7672 14.3522 20.3149 15.4442C19.8626 16.5361 19.1997 17.5282 18.364 18.364C17.5282 19.1997 16.5361 19.8626 15.4442 20.3149C14.3522 20.7672 13.1819 21 12 21C10.8181 21 9.64778 20.7672 8.55585 20.3149C7.46392 19.8626 6.47177 19.1997 5.63604 18.364C4.80031 17.5282 4.13738 16.5361 3.68508 15.4442C3.23279 14.3522 3 13.1819 3 12C3 9.61305 3.94821 7.32387 5.63604 5.63604C7.32387 3.94821 9.61305 3 12 3C14.3869 3 16.6761 3.94821 18.364 5.63604C20.0518 7.32387 21 9.61305 21 12Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 770 B |
19
src/icons/Download.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
fill="currentColor"
|
||||
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>
|
After Width: | Height: | Size: 1.8 KiB |
12
src/icons/Edit.svelte
Normal file
|
@ -0,0 +1,12 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
>
|
||||
<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>
|
After Width: | Height: | Size: 423 B |
16
src/icons/Expand.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M19 9L12 16L5 9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 292 B |
19
src/icons/ExternalLink.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
id="Vector"
|
||||
d="M10.0002 5H8.2002C7.08009 5 6.51962 5 6.0918 5.21799C5.71547 5.40973 5.40973 5.71547 5.21799 6.0918C5 6.51962 5 7.08009 5 8.2002V15.8002C5 16.9203 5 17.4801 5.21799 17.9079C5.40973 18.2842 5.71547 18.5905 6.0918 18.7822C6.5192 19 7.07899 19 8.19691 19H15.8031C16.921 19 17.48 19 17.9074 18.7822C18.2837 18.5905 18.5905 18.2839 18.7822 17.9076C19 17.4802 19 16.921 19 15.8031V14M20 9V4M20 4H15M20 4L13 11"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 725 B |
17
src/icons/Feed.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 8v8m0-8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4Zm8-8a2 2 0 1 0 0-4 2 2 0 0 0 0 4Zm0 0a4 4 0 0 1-4 4h-1a3 3 0 0 0-3 3"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 438 B |
15
src/icons/GitHub.svelte
Normal file
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
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>
|
After Width: | Height: | Size: 1.5 KiB |
17
src/icons/Guide.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<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>
|
After Width: | Height: | Size: 1.7 KiB |
14
src/icons/LightMode.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
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>
|
After Width: | Height: | Size: 737 B |
21
src/icons/Link.svelte
Normal file
|
@ -0,0 +1,21 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M7.05025 1.53553C8.03344 0.552348 9.36692 0 10.7574 0C13.6528 0 16 2.34721 16 5.24264C16 6.63308 15.4477 7.96656 14.4645 8.94975L12.4142 11L11 9.58579L13.0503 7.53553C13.6584 6.92742 14 6.10264 14 5.24264C14 3.45178 12.5482 2 10.7574 2C9.89736 2 9.07258 2.34163 8.46447 2.94975L6.41421 5L5 3.58579L7.05025 1.53553Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M7.53553 13.0503L9.58579 11L11 12.4142L8.94975 14.4645C7.96656 15.4477 6.63308 16 5.24264 16C2.34721 16 0 13.6528 0 10.7574C0 9.36693 0.552347 8.03344 1.53553 7.05025L3.58579 5L5 6.41421L2.94975 8.46447C2.34163 9.07258 2 9.89736 2 10.7574C2 12.5482 3.45178 14 5.24264 14C6.10264 14 6.92742 13.6584 7.53553 13.0503Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M5.70711 11.7071L11.7071 5.70711L10.2929 4.29289L4.29289 10.2929L5.70711 11.7071Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 1,000 B |
21
src/icons/Menu.svelte
Normal file
|
@ -0,0 +1,21 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 12C9.10457 12 10 12.8954 10 14C10 15.1046 9.10457 16 8 16C6.89543 16 6 15.1046 6 14C6 12.8954 6.89543 12 8 12Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M8 6C9.10457 6 10 6.89543 10 8C10 9.10457 9.10457 10 8 10C6.89543 10 6 9.10457 6 8C6 6.89543 6.89543 6 8 6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M10 2C10 0.89543 9.10457 -4.82823e-08 8 0C6.89543 4.82823e-08 6 0.895431 6 2C6 3.10457 6.89543 4 8 4C9.10457 4 10 3.10457 10 2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 638 B |
17
src/icons/Remove.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 7h14m-9 3v8m4-8v8M10 3h4a1 1 0 0 1 1 1v3H9V4a1 1 0 0 1 1-1ZM6 7h12v13a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V7Z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 403 B |
15
src/icons/Reset.svelte
Normal file
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 25 25"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M6.01409 11.5886C6.62899 7.21335 10.6743 4.16497 15.0496 4.77987C19.4249 5.39478 22.4733 9.44012 21.8584 13.8154C21.2435 18.1907 17.1981 21.2391 12.8228 20.6242C11.944 20.5007 11.1206 20.2394 10.3701 19.866C9.87569 19.6199 9.2754 19.8213 9.02937 20.3158C8.78333 20.8102 8.98472 21.4105 9.47917 21.6565C10.4198 22.1246 11.4499 22.4509 12.5445 22.6047C18.0136 23.3733 23.0703 19.5628 23.8389 14.0937C24.6075 8.62465 20.7971 3.56797 15.328 2.79934C9.85886 2.0307 4.80218 5.84119 4.03355 11.3103C4.02368 11.3805 4.01456 11.4507 4.00619 11.5209L2.97388 10.1944C2.63469 9.75851 2.00639 9.68015 1.57054 10.0193C1.13469 10.3585 1.05632 10.9868 1.39551 11.4227L3.74048 14.4359C4.15069 14.963 4.90281 15.0745 5.4483 14.6891L8.61937 12.4485C9.07042 12.1297 9.1777 11.5057 8.859 11.0547C8.54029 10.6036 7.91628 10.4964 7.46523 10.8151L5.98009 11.8644C5.98982 11.7727 6.00114 11.6808 6.01409 11.5886Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.1 KiB |
14
src/icons/Search.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
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>
|
After Width: | Height: | Size: 349 B |
17
src/icons/SelectAll.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.5 11.5 11 14l4-4m6 2a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 352 B |
15
src/icons/Share.svelte
Normal file
|
@ -0,0 +1,15 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M23 5.5C23 7.98528 20.9853 10 18.5 10C17.0993 10 15.8481 9.36007 15.0228 8.35663L9.87308 10.9315C9.95603 11.2731 10 11.63 10 11.9971C10 12.3661 9.9556 12.7247 9.87184 13.0678L15.0228 15.6433C15.8482 14.6399 17.0993 14 18.5 14C20.9853 14 23 16.0147 23 18.5C23 20.9853 20.9853 23 18.5 23C16.0147 23 14 20.9853 14 18.5C14 18.1319 14.0442 17.7742 14.1276 17.4318L8.97554 14.8558C8.1502 15.8581 6.89973 16.4971 5.5 16.4971C3.01472 16.4971 1 14.4824 1 11.9971C1 9.51185 3.01472 7.49713 5.5 7.49713C6.90161 7.49713 8.15356 8.13793 8.97886 9.14254L14.1275 6.5682C14.0442 6.2258 14 5.86806 14 5.5C14 3.01472 16.0147 1 18.5 1C20.9853 1 23 3.01472 23 5.5ZM16.0029 5.5C16.0029 6.87913 17.1209 7.99713 18.5 7.99713C19.8791 7.99713 20.9971 6.87913 20.9971 5.5C20.9971 4.12087 19.8791 3.00287 18.5 3.00287C17.1209 3.00287 16.0029 4.12087 16.0029 5.5ZM16.0029 18.5C16.0029 19.8791 17.1209 20.9971 18.5 20.9971C19.8791 20.9971 20.9971 19.8791 20.9971 18.5C20.9971 17.1209 19.8791 16.0029 18.5 16.0029C17.1209 16.0029 16.0029 17.1209 16.0029 18.5ZM5.5 14.4943C4.12087 14.4943 3.00287 13.3763 3.00287 11.9971C3.00287 10.618 4.12087 9.5 5.5 9.5C6.87913 9.5 7.99713 10.618 7.99713 11.9971C7.99713 13.3763 6.87913 14.4943 5.5 14.4943Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 1.4 KiB |
25
src/icons/Spinner.svelte
Normal file
|
@ -0,0 +1,25 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
><style>
|
||||
.spinner_ajPY {
|
||||
transform-origin: center;
|
||||
animation: spinner_AtaB 0.75s infinite linear;
|
||||
}
|
||||
@keyframes spinner_AtaB {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style><path
|
||||
d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z"
|
||||
opacity=".25"
|
||||
/><path
|
||||
d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z"
|
||||
class="spinner_ajPY"
|
||||
/></svg
|
||||
>
|
After Width: | Height: | Size: 696 B |
16
src/icons/Stream.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<svg
|
||||
{...$$restProps}
|
||||
width={$$props.size}
|
||||
height={$$props.size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 15.1001C3.96089 15.2961 4.84294 15.7703 5.53638 16.4637C6.22982 17.1572 6.70403 18.0392 6.9 19.0001M3 19H3.01M3 11.0498C5.03079 11.2757 6.92428 12.1859 8.36911 13.6307C9.81395 15.0755 10.7241 16.969 10.95 18.9998M15 19H17.8C18.9201 19 19.4802 19 19.908 18.782C20.2843 18.5903 20.5903 18.2843 20.782 17.908C21 17.4802 21 16.9201 21 15.8V8.2C21 7.0799 21 6.51984 20.782 6.09202C20.5903 5.71569 20.2843 5.40973 19.908 5.21799C19.4802 5 18.9201 5 17.8 5H5C3.89543 5 3 5.89543 3 7"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
After Width: | Height: | Size: 755 B |
29
src/icons/index.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
export { default as Add } from './Add.svelte'
|
||||
export { default as CheckboxChecked } from './CheckboxChecked.svelte'
|
||||
export { default as CheckboxDisabled } from './CheckboxDisabled.svelte'
|
||||
export { default as CheckboxIndeterminate } from './CheckboxIndeterminate.svelte'
|
||||
export { default as CheckboxUnchecked } from './CheckboxUnchecked.svelte'
|
||||
export { default as Check } from './Check.svelte'
|
||||
export { default as Clear } from './Clear.svelte'
|
||||
export { default as Close } from './Close.svelte'
|
||||
export { default as Copy } from './Copy.svelte'
|
||||
export { default as CreatePlaylist } from './CreatePlaylist.svelte'
|
||||
export { default as DarkMode } from './DarkMode.svelte'
|
||||
export { default as DeselectAll } from './DeselectAll.svelte'
|
||||
export { default as Download } from './Download.svelte'
|
||||
export { default as Edit } from './Edit.svelte'
|
||||
export { default as Expand } from './Expand.svelte'
|
||||
export { default as ExternalLink } from './ExternalLink.svelte'
|
||||
export { default as Feed } from './Feed.svelte'
|
||||
export { default as GitHub } from './GitHub.svelte'
|
||||
export { default as Guide } from './Guide.svelte'
|
||||
export { default as LightMode } from './LightMode.svelte'
|
||||
export { default as Link } from './Link.svelte'
|
||||
export { default as Menu } from './Menu.svelte'
|
||||
export { default as Remove } from './Remove.svelte'
|
||||
export { default as Reset } from './Reset.svelte'
|
||||
export { default as Search } from './Search.svelte'
|
||||
export { default as SelectAll } from './SelectAll.svelte'
|
||||
export { default as Share } from './Share.svelte'
|
||||
export { default as Stream } from './Stream.svelte'
|
||||
export { default as Spinner } from './Spinner.svelte'
|
22
src/load.js
|
@ -1,22 +0,0 @@
|
|||
import { Storage } from '@freearhey/core'
|
||||
import { ApiClient } from './utils/apiClient.js'
|
||||
|
||||
async function main() {
|
||||
const client = new ApiClient({ storage: new Storage('src/data') })
|
||||
|
||||
const requests = [
|
||||
client.download('blocklist.json'),
|
||||
client.download('categories.json'),
|
||||
client.download('channels.json'),
|
||||
client.download('streams.json'),
|
||||
client.download('guides.json'),
|
||||
client.download('countries.json'),
|
||||
client.download('languages.json'),
|
||||
client.download('regions.json'),
|
||||
client.download('subdivisions.json')
|
||||
]
|
||||
|
||||
await Promise.all(requests)
|
||||
}
|
||||
|
||||
main()
|
50
src/models/blocklistRecord.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
import type { BlocklistRecordData, BlocklistRecordSerializedData } from '~/types/blocklistRecord'
|
||||
|
||||
export class BlocklistRecord {
|
||||
channelId: string
|
||||
reason: string
|
||||
refUrl: string
|
||||
|
||||
constructor(data?: BlocklistRecordData) {
|
||||
if (!data) return
|
||||
|
||||
this.channelId = data.channel
|
||||
this.reason = data.reason
|
||||
this.refUrl = data.ref
|
||||
}
|
||||
|
||||
getRefLabel(): string {
|
||||
let refLabel = ''
|
||||
|
||||
const isIssue = /issues|pull/.test(this.refUrl)
|
||||
const isAttachment = /github\.zendesk\.com\/attachments\/token/.test(this.refUrl)
|
||||
if (isIssue) {
|
||||
const parts = this.refUrl.split('/')
|
||||
const issueId = parts.pop()
|
||||
refLabel = `#${issueId}`
|
||||
} else if (isAttachment) {
|
||||
const [, filename] = this.refUrl.match(/\?name=(.*)/) || [null, undefined]
|
||||
refLabel = filename
|
||||
} else {
|
||||
refLabel = this.refUrl.split('/').pop()
|
||||
}
|
||||
|
||||
return refLabel
|
||||
}
|
||||
|
||||
serialize(): BlocklistRecordSerializedData {
|
||||
return {
|
||||
channelId: this.channelId,
|
||||
reason: this.reason,
|
||||
refUrl: this.refUrl
|
||||
}
|
||||
}
|
||||
|
||||
deserialize(data: BlocklistRecordSerializedData): this {
|
||||
this.channelId = data.channelId
|
||||
this.reason = data.reason
|
||||
this.refUrl = data.refUrl
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
195
src/models/broadcastArea.ts
Normal file
|
@ -0,0 +1,195 @@
|
|||
import type { BroadcastAreaData, BroadcastAreaSerializedData } from '~/types/broadcastArea'
|
||||
import type { SubdivisionSerializedData } from '~/types/subdivision'
|
||||
import type { CountrySerializedData } from '~/types/country'
|
||||
import type { RegionSerializedData } from '~/types/region'
|
||||
import { type Dictionary, Collection } from '@freearhey/core/browser'
|
||||
import { Region, Country, Subdivision } from './'
|
||||
|
||||
export class BroadcastArea {
|
||||
code: string
|
||||
name?: string
|
||||
countries?: Collection
|
||||
subdivisions?: Collection
|
||||
regions?: Collection
|
||||
|
||||
constructor(data?: BroadcastAreaData) {
|
||||
if (!data) return
|
||||
|
||||
this.code = data.code
|
||||
}
|
||||
|
||||
withName(
|
||||
countriesKeyByCode: Dictionary,
|
||||
subdivisionsKeyByCode: Dictionary,
|
||||
regionsKeyByCode: Dictionary
|
||||
): this {
|
||||
const [type, code] = this.code.split('/')
|
||||
|
||||
switch (type) {
|
||||
case 's': {
|
||||
const subdivision: Subdivision = subdivisionsKeyByCode.get(code)
|
||||
if (subdivision) this.name = subdivision.name
|
||||
break
|
||||
}
|
||||
case 'c': {
|
||||
const country: Country = countriesKeyByCode.get(code)
|
||||
if (country) this.name = country.name
|
||||
break
|
||||
}
|
||||
case 'r': {
|
||||
const region: Region = regionsKeyByCode.get(code)
|
||||
if (region) this.name = region.name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
withLocations(
|
||||
countriesKeyByCode: Dictionary,
|
||||
subdivisionsKeyByCode: Dictionary,
|
||||
regionsKeyByCode: Dictionary,
|
||||
regions: Collection
|
||||
): this {
|
||||
const [type, code] = this.code.split('/')
|
||||
|
||||
let _countries = new Collection()
|
||||
let _regions = new Collection()
|
||||
let _subdivisions = new Collection()
|
||||
|
||||
regions = regions.filter((region: Region) => region.code !== 'INT')
|
||||
|
||||
switch (type) {
|
||||
case 's': {
|
||||
const subdivision: Subdivision = subdivisionsKeyByCode.get(code)
|
||||
if (!subdivision) break
|
||||
_subdivisions.add(subdivision)
|
||||
const country: Country = countriesKeyByCode.get(subdivision.countryCode)
|
||||
if (!country) break
|
||||
_countries.add(country)
|
||||
const countryRegions = regions.filter((region: Region) =>
|
||||
region.countryCodes.includes(country.code)
|
||||
)
|
||||
countryRegions.forEach((region: Region) => {
|
||||
_regions.add(region)
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'c': {
|
||||
const country = countriesKeyByCode.get(code)
|
||||
if (!country) break
|
||||
_countries.add(country)
|
||||
const countryRegions = regions.filter((region: Region) =>
|
||||
region.countryCodes.includes(country.code)
|
||||
)
|
||||
countryRegions.forEach((region: Region) => {
|
||||
_regions.add(region)
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'r': {
|
||||
const region: Region = regionsKeyByCode.get(code)
|
||||
if (!region) break
|
||||
_regions.add(region)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.countries = _countries.uniqBy((country: Country) => country.code)
|
||||
this.regions = _regions.uniqBy((region: Region) => region.code)
|
||||
this.subdivisions = _subdivisions.uniqBy((subdivision: Subdivision) => subdivision.code)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
getName(): string {
|
||||
return this.name || ''
|
||||
}
|
||||
|
||||
getCountries(): Collection {
|
||||
if (!this.countries) return new Collection()
|
||||
|
||||
return this.countries
|
||||
}
|
||||
|
||||
getRegions(): Collection {
|
||||
if (!this.regions) return new Collection()
|
||||
|
||||
return this.regions
|
||||
}
|
||||
|
||||
getSubdivisions(): Collection {
|
||||
if (!this.subdivisions) return new Collection()
|
||||
|
||||
return this.subdivisions
|
||||
}
|
||||
|
||||
getLocationCodes(): Collection {
|
||||
let locationCodes = new Collection()
|
||||
|
||||
this.getCountries().forEach((country: Country) => {
|
||||
locationCodes.add(country.code)
|
||||
})
|
||||
|
||||
this.getRegions().forEach((region: Region) => {
|
||||
locationCodes.add(region.code)
|
||||
})
|
||||
|
||||
this.getSubdivisions().forEach((subdivision: Subdivision) => {
|
||||
locationCodes.add(subdivision.code)
|
||||
})
|
||||
|
||||
return locationCodes
|
||||
}
|
||||
|
||||
getLocationNames(): Collection {
|
||||
let locationNames = new Collection()
|
||||
|
||||
this.getCountries().forEach((country: Country) => {
|
||||
locationNames.add(country.name)
|
||||
})
|
||||
|
||||
this.getRegions().forEach((region: Region) => {
|
||||
locationNames.add(region.name)
|
||||
})
|
||||
|
||||
this.getSubdivisions().forEach((subdivision: Subdivision) => {
|
||||
locationNames.add(subdivision.name)
|
||||
})
|
||||
|
||||
return locationNames
|
||||
}
|
||||
|
||||
serialize(): BroadcastAreaSerializedData {
|
||||
return {
|
||||
code: this.code,
|
||||
name: this.getName(),
|
||||
countries: this.getCountries()
|
||||
.map((country: Country) => country.serialize())
|
||||
.all(),
|
||||
subdivisions: this.getSubdivisions()
|
||||
.map((subdivision: Subdivision) => subdivision.serialize())
|
||||
.all(),
|
||||
regions: this.getRegions()
|
||||
.map((region: Region) => region.serialize())
|
||||
.all()
|
||||
}
|
||||
}
|
||||
|
||||
deserialize(data: BroadcastAreaSerializedData): this {
|
||||
this.code = data.code
|
||||
this.name = data.name
|
||||
this.countries = new Collection(data.countries).map((data: CountrySerializedData) =>
|
||||
new Country().deserialize(data)
|
||||
)
|
||||
this.subdivisions = new Collection(data.subdivisions).map((data: SubdivisionSerializedData) =>
|
||||
new Subdivision().deserialize(data)
|
||||
)
|
||||
this.regions = new Collection(data.regions).map((data: RegionSerializedData) =>
|
||||
new Region().deserialize(data)
|
||||
)
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
27
src/models/category.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import type { CategoryData, CategorySerializedData } from '~/types/category'
|
||||
|
||||
export class Category {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
constructor(data?: CategoryData) {
|
||||
if (!data) return
|
||||
|
||||
this.id = data.id
|
||||
this.name = data.name
|
||||
}
|
||||
|
||||
serialize(): CategorySerializedData {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name
|
||||
}
|
||||
}
|
||||
|
||||
deserialize(data: CategorySerializedData): this {
|
||||
this.id = data.id
|
||||
this.name = data.name
|
||||
|
||||
return this
|
||||
}
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
export class Channel {
|
||||
constructor(data) {
|
||||
const _streams = Array.isArray(data.streams) ? data.streams : []
|
||||
const _guides = Array.isArray(data.guides) ? data.guides : []
|
||||
const _blocklistRecords = Array.isArray(data.blocklistRecords) ? data.blocklistRecords : []
|
||||
|
||||
this.id = data.id
|
||||
this.name = data.name
|
||||
this.alt_names = this.alt_name = data.altNames
|
||||
this.network = data.network
|
||||
this.owners = this.owner = data.owners
|
||||
this.city = data.city
|
||||
this.country = [data.country?.code, data.country?.name].filter(Boolean)
|
||||
this.subdivision = data.subdivision?.code || null
|
||||
this.languages = this.language = [
|
||||
...data.languages.map(language => language.code),
|
||||
...data.languages.map(language => language.name)
|
||||
]
|
||||
this.categories = this.category = data.categories.map(category => category.name)
|
||||
this.broadcast_area = [
|
||||
...data.broadcastArea.map(area => `${area.type}/${area.code}`).filter(Boolean),
|
||||
...data.broadcastArea.map(area => area.name).filter(Boolean),
|
||||
...data.regionCountries.map(country => country.code).filter(Boolean),
|
||||
...data.regionCountries.map(country => country.name).filter(Boolean)
|
||||
]
|
||||
this.is_nsfw = data.isNSFW
|
||||
this.launched = data.launched
|
||||
this.closed = data.closed
|
||||
this.is_closed = !!data.closed || !!data.replacedBy
|
||||
this.replaced_by = data.replacedBy
|
||||
this.website = data.website
|
||||
this.logo = data.logo
|
||||
this.streams = _streams.length
|
||||
this.guides = _guides.length
|
||||
this.is_blocked = _blocklistRecords.length > 0
|
||||
|
||||
this._hasUniqueName = data.hasUniqueName
|
||||
this._displayName = data.hasUniqueName ? data.name : `${data.name} (${data.country?.name})`
|
||||
this._country = data.country
|
||||
this._subdivision = data.subdivision || null
|
||||
this._languages = data.languages
|
||||
this._categories = data.categories
|
||||
this._broadcastArea = data.broadcastArea
|
||||
this._streams = _streams
|
||||
this._guides = _guides
|
||||
this._blocklistRecords = _blocklistRecords
|
||||
this._guideNames = _guides.map(guide => guide.site_name).filter(Boolean)
|
||||
this._streamUrls = _streams.map(stream => stream.url).filter(Boolean)
|
||||
}
|
||||
|
||||
toObject() {
|
||||
const { ...object } = this
|
||||
|
||||
return object
|
||||
}
|
||||
}
|
537
src/models/channel.ts
Normal file
|
@ -0,0 +1,537 @@
|
|||
import type { ChannelSearchable, ChannelSerializedData, ChannelData } from '../types/channel'
|
||||
import type { BlocklistRecordSerializedData } from '~/types/blocklistRecord'
|
||||
import type { HTMLPreviewField } from '../types/htmlPreviewField'
|
||||
import type { CategorySerializedData } from '~/types/category'
|
||||
import type { FeedSerializedData } from '~/types/feed'
|
||||
import { Collection, type Dictionary } from '@freearhey/core/browser'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
BlocklistRecord,
|
||||
BroadcastArea,
|
||||
Subdivision,
|
||||
Category,
|
||||
Language,
|
||||
Country,
|
||||
Stream,
|
||||
Guide,
|
||||
Feed
|
||||
} from '.'
|
||||
|
||||
export class Channel {
|
||||
id: string
|
||||
name: string
|
||||
altNames: Collection = new Collection()
|
||||
networkName?: string
|
||||
ownerNames: Collection = new Collection()
|
||||
countryCode: string
|
||||
country?: Country
|
||||
subdivisionCode?: string
|
||||
subdivision?: Subdivision
|
||||
cityName?: string
|
||||
categoryIds: Collection = new Collection()
|
||||
categories: Collection = new Collection()
|
||||
isNSFW: boolean
|
||||
launchedDateString?: string
|
||||
launchedDate?: Dayjs
|
||||
closedDateString?: string
|
||||
closedDate?: Dayjs
|
||||
replacedByStreamId?: string
|
||||
replacedByChannelId?: string
|
||||
websiteUrl?: string
|
||||
logoUrl: string
|
||||
blocklistRecords: Collection = new Collection()
|
||||
feeds: Collection = new Collection()
|
||||
hasUniqueName: boolean = true
|
||||
|
||||
constructor(data?: ChannelData) {
|
||||
if (!data) return
|
||||
|
||||
this.id = data.id
|
||||
this.name = data.name
|
||||
this.altNames = new Collection(data.alt_names)
|
||||
this.networkName = data.network
|
||||
this.ownerNames = new Collection(data.owners)
|
||||
this.countryCode = data.country
|
||||
this.subdivisionCode = data.subdivision
|
||||
this.cityName = data.city
|
||||
this.categoryIds = new Collection(data.categories)
|
||||
this.isNSFW = data.is_nsfw
|
||||
this.launchedDateString = data.launched
|
||||
this.launchedDate = data.launched ? dayjs(data.launched) : undefined
|
||||
this.closedDateString = data.closed
|
||||
this.closedDate = data.closed ? dayjs(data.closed) : undefined
|
||||
this.replacedByStreamId = data.replaced_by || undefined
|
||||
const [replacedByChannelId] = data.replaced_by ? data.replaced_by.split('@') : [undefined]
|
||||
this.replacedByChannelId = replacedByChannelId
|
||||
this.websiteUrl = data.website
|
||||
this.logoUrl = data.logo
|
||||
}
|
||||
|
||||
withCountry(countriesKeyByCode: Dictionary): this {
|
||||
this.country = countriesKeyByCode.get(this.countryCode)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
withSubdivision(subdivisionsKeyByCode: Dictionary): this {
|
||||
if (!this.subdivisionCode) return this
|
||||
|
||||
this.subdivision = subdivisionsKeyByCode.get(this.subdivisionCode)
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
withCategories(categoriesKeyById: Dictionary): this {
|
||||
this.categories = this.categoryIds.map((id: string) => categoriesKeyById.get(id))
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
withBlocklistRecords(blocklistGroupedByChannelId: Dictionary): this {
|
||||
this.blocklistRecords = new Collection(blocklistGroupedByChannelId.get(this.id))
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
withFeeds(feedsGroupedByChannelId: Dictionary): this {
|
||||
this.feeds = new Collection(feedsGroupedByChannelId.get(this.id))
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
setHasUniqueName(channelsGroupedByName: Dictionary): this {
|
||||
this.hasUniqueName = new Collection(channelsGroupedByName.get(this.name)).count() === 1
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
getUniqueName(): string {
|
||||
if (this.hasUniqueName) return this.name
|
||||
if (!this.country) return this.name
|
||||
|
||||
return `${this.name} (${this.country.name})`
|
||||
}
|
||||
|
||||
getCategories(): Collection {
|
||||
if (!this.categories) return new Collection()
|
||||
|
||||
return this.categories
|
||||
}
|
||||
|
||||
getStreams(): Collection {
|
||||
let streams = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
streams = streams.concat(feed.getStreams())
|
||||
})
|
||||
|
||||
return streams
|
||||
}
|
||||
|
||||
getGuides(): Collection {
|
||||
let guides = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
guides = guides.concat(feed.getGuides())
|
||||
})
|
||||
|
||||
return guides
|
||||
}
|
||||
|
||||
getLanguages(): Collection {
|
||||
let languages = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
languages = languages.concat(feed.getLanguages())
|
||||
})
|
||||
|
||||
return languages.uniqBy((language: Language) => language.code)
|
||||
}
|
||||
|
||||
getLanguageCodes(): Collection {
|
||||
return this.getLanguages().map((language: Language) => language.code)
|
||||
}
|
||||
|
||||
getLanguageNames(): Collection {
|
||||
return this.getLanguages().map((language: Language) => language.name)
|
||||
}
|
||||
|
||||
getBroadcastAreaCodes(): Collection {
|
||||
let broadcastAreaCodes = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
broadcastAreaCodes = broadcastAreaCodes.concat(feed.broadcastAreaCodes)
|
||||
})
|
||||
|
||||
return broadcastAreaCodes.uniq()
|
||||
}
|
||||
|
||||
hasGuides(): boolean {
|
||||
return this.getGuides().notEmpty()
|
||||
}
|
||||
|
||||
hasFeeds(): boolean {
|
||||
return this.getFeeds().notEmpty()
|
||||
}
|
||||
|
||||
hasStreams(): boolean {
|
||||
return this.getStreams().notEmpty()
|
||||
}
|
||||
|
||||
getDisplayName(): string {
|
||||
return this.name
|
||||
}
|
||||
|
||||
getPagePath(): string {
|
||||
const [channelSlug, countryCode] = this.id.split('.')
|
||||
if (!channelSlug || !countryCode) return ''
|
||||
|
||||
return `/channels/${countryCode}/${channelSlug}`
|
||||
}
|
||||
|
||||
getPageUrl(): string {
|
||||
const [channelSlug, countryCode] = this.id.split('.') || [null, null]
|
||||
if (!channelSlug || !countryCode || typeof window === 'undefined') return ''
|
||||
|
||||
return `${window.location.protocol}//${window.location.host}/channels/${countryCode}/${channelSlug}`
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
return !!this.closedDateString || !!this.replacedByStreamId
|
||||
}
|
||||
|
||||
isBlocked(): boolean {
|
||||
return this.blocklistRecords ? this.blocklistRecords.notEmpty() : false
|
||||
}
|
||||
|
||||
getCountryName(): string {
|
||||
return this.country ? this.country.name : ''
|
||||
}
|
||||
|
||||
getGuideSiteNames(): Collection {
|
||||
return this.getGuides().map((guide: Guide) => guide.siteName)
|
||||
}
|
||||
|
||||
getStreamUrls(): Collection {
|
||||
return this.getStreams().map((stream: Stream) => stream.url)
|
||||
}
|
||||
|
||||
getFeeds(): Collection {
|
||||
if (!this.feeds) return new Collection()
|
||||
|
||||
return this.feeds
|
||||
}
|
||||
|
||||
getBroadcastLocationCodes(): Collection {
|
||||
let broadcastLocationCodes = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
broadcastLocationCodes = broadcastLocationCodes.concat(feed.getBroadcastLocationCodes())
|
||||
})
|
||||
|
||||
return broadcastLocationCodes.uniq()
|
||||
}
|
||||
|
||||
getBroadcastLocationNames(): Collection {
|
||||
let broadcastLocationNames = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
broadcastLocationNames = broadcastLocationNames.concat(feed.getBroadcastLocationNames())
|
||||
})
|
||||
|
||||
return broadcastLocationNames.uniq()
|
||||
}
|
||||
|
||||
getVideoFormats(): Collection {
|
||||
let videoFormats = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
videoFormats.add(feed.videoFormat)
|
||||
})
|
||||
|
||||
return videoFormats.uniq()
|
||||
}
|
||||
|
||||
getTimezoneIds(): Collection {
|
||||
let timezoneIds = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
timezoneIds = timezoneIds.concat(feed.timezoneIds)
|
||||
})
|
||||
|
||||
return timezoneIds.uniq()
|
||||
}
|
||||
|
||||
getBroadcastArea(): Collection {
|
||||
let broadcastArea = new Collection()
|
||||
|
||||
this.getFeeds().forEach((feed: Feed) => {
|
||||
broadcastArea = broadcastArea.concat(feed.getBroadcastArea())
|
||||
})
|
||||
|
||||
return broadcastArea.uniqBy((broadcastArea: BroadcastArea) => broadcastArea.code)
|
||||
}
|
||||
|
||||
getSearchable(): ChannelSearchable {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
alt_names: this.altNames.all(),
|
||||
alt_name: this.altNames.all(),
|
||||
network: this.networkName,
|
||||
owner: this.ownerNames.all(),
|
||||
owners: this.ownerNames.all(),
|
||||
country: this.countryCode,
|
||||
subdivision: this.subdivisionCode,
|
||||
city: this.cityName,
|
||||
category: this.categoryIds.all(),
|
||||
categories: this.categoryIds.all(),
|
||||
launched: this.launchedDateString,
|
||||
closed: this.closedDateString,
|
||||
replaced_by: this.replacedByStreamId,
|
||||
website: this.websiteUrl,
|
||||
is_nsfw: this.isNSFW,
|
||||
is_closed: this.isClosed(),
|
||||
is_blocked: this.isBlocked(),
|
||||
feeds: this.getFeeds().count(),
|
||||
streams: this.getStreams().count(),
|
||||
guides: this.getGuides().count(),
|
||||
language: this.getLanguageCodes().all(),
|
||||
languages: this.getLanguageCodes().all(),
|
||||
broadcast_area: this.getBroadcastAreaCodes().all(),
|
||||
video_format: this.getVideoFormats().all(),
|
||||
video_formats: this.getVideoFormats().all(),
|
||||
timezone: this.getTimezoneIds().all(),
|
||||
timezones: this.getTimezoneIds().all(),
|
||||
_languageNames: this.getLanguageNames().all(),
|
||||
_broadcastLocationCodes: this.getBroadcastLocationCodes().all(),
|
||||
_broadcastLocationNames: this.getBroadcastLocationNames().all(),
|
||||
_countryName: this.getCountryName(),
|
||||
_guideSiteNames: this.getGuideSiteNames().all(),
|
||||
_streamUrls: this.getStreamUrls().all()
|
||||
}
|
||||
}
|
||||
|
||||
serialize(props = { withFeeds: true }): ChannelSerializedData {
|
||||
return {
|
||||
id: this.id,
|
||||
name: this.name,
|
||||
altNames: this.altNames.all(),
|
||||
networkName: this.networkName,
|
||||
ownerNames: this.ownerNames.all(),
|
||||
countryCode: this.countryCode,
|
||||
country: this.country ? this.country.serialize() : null,
|
||||
subdivisionCode: this.subdivisionCode,
|
||||
subdivision: this.subdivision ? this.subdivision.serialize() : null,
|
||||
cityName: this.cityName,
|
||||
categoryIds: this.categoryIds.all(),
|
||||
categories: this.categories.map((category: Category) => category.serialize()).all(),
|
||||
isNSFW: this.isNSFW,
|
||||
launchedDateString: this.launchedDateString,
|
||||
launchedDate: this.launchedDate ? this.launchedDate.toJSON() : null,
|
||||
closedDateString: this.closedDateString,
|
||||
closedDate: this.closedDate ? this.closedDate.toJSON() : null,
|
||||
replacedByStreamId: this.replacedByStreamId,
|
||||
replacedByChannelId: this.replacedByChannelId,
|
||||
websiteUrl: this.websiteUrl,
|
||||
logoUrl: this.logoUrl,
|
||||
blocklistRecords: this.blocklistRecords
|
||||
.map((blocklistRecord: BlocklistRecord) => blocklistRecord.serialize())
|
||||
.all(),
|
||||
feeds: props.withFeeds
|
||||
? this.getFeeds()
|
||||
.map((feed: Feed) => feed.serialize())
|
||||
.all()
|
||||
: [],
|
||||
hasUniqueName: this.hasUniqueName
|
||||
}
|
||||
}
|
||||
|
||||
deserialize(data: ChannelSerializedData): this {
|
||||
this.id = data.id || ''
|
||||
this.name = data.name
|
||||
this.altNames = new Collection(data.altNames)
|
||||
this.networkName = data.networkName
|
||||
this.ownerNames = new Collection(data.ownerNames)
|
||||
this.countryCode = data.countryCode
|
||||
this.country = new Country().deserialize(data.country)
|
||||
this.subdivisionCode = data.subdivisionCode
|
||||
this.cityName = data.cityName
|
||||
this.categoryIds = new Collection(data.categoryIds)
|
||||
this.categories = new Collection(data.categories).map((data: CategorySerializedData) =>
|
||||
new Category().deserialize(data)
|
||||
)
|
||||
this.isNSFW = data.isNSFW
|
||||
this.launchedDateString = data.launchedDateString
|
||||
this.launchedDate = data.launchedDate ? dayjs(data.launchedDate) : undefined
|
||||
this.closedDateString = data.closedDateString
|
||||
this.closedDate = data.closedDate ? dayjs(data.closedDate) : undefined
|
||||
this.replacedByStreamId = data.replacedByStreamId
|
||||
this.replacedByChannelId = data.replacedByChannelId
|
||||
this.websiteUrl = data.websiteUrl
|
||||
this.logoUrl = data.logoUrl
|
||||
this.blocklistRecords = new Collection(data.blocklistRecords).map(
|
||||
(data: BlocklistRecordSerializedData) => new BlocklistRecord().deserialize(data)
|
||||
)
|
||||
this.feeds = new Collection(data.feeds).map((data: FeedSerializedData) =>
|
||||
new Feed().deserialize(data)
|
||||
)
|
||||
this.hasUniqueName = data.hasUniqueName
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
getFieldset(): HTMLPreviewField[] {
|
||||
return [
|
||||
{
|
||||
name: 'logo',
|
||||
type: 'image',
|
||||
value: { src: this.logoUrl, alt: `${this.name} logo`, title: this.logoUrl }
|
||||
},
|
||||
{ name: 'id', type: 'string', value: this.id, title: this.id },
|
||||
{ name: 'name', type: 'string', value: this.name, title: this.name },
|
||||
{ name: 'alt_names', type: 'string[]', value: this.altNames.all() },
|
||||
{
|
||||
name: 'network',
|
||||
type: 'link',
|
||||
value: this.networkName
|
||||
? { label: this.networkName, query: `network:${normalize(this.networkName)}` }
|
||||
: null
|
||||
},
|
||||
{
|
||||
name: 'owners',
|
||||
type: 'link[]',
|
||||
value: this.ownerNames
|
||||
.map((name: string) => ({
|
||||
label: name,
|
||||
query: `owner:${normalize(name)}`
|
||||
}))
|
||||
.all()
|
||||
},
|
||||
{
|
||||
name: 'country',
|
||||
type: 'link',
|
||||
value: this.country
|
||||
? { label: this.country.name, query: `country:${this.country.code}` }
|
||||
: null
|
||||
},
|
||||
{
|
||||
name: 'subdivision',
|
||||
type: 'link',
|
||||
value: this.subdivision
|
||||
? { label: this.subdivision.name, query: `subdivision:${this.subdivision.code}` }
|
||||
: null
|
||||
},
|
||||
{
|
||||
name: 'city',
|
||||
type: 'link',
|
||||
value: this.cityName ? { label: this.cityName, query: `city:${this.cityName}` } : null
|
||||
},
|
||||
{
|
||||
name: 'broadcast_area',
|
||||
type: 'link[]',
|
||||
value: this.getBroadcastArea()
|
||||
.map((broadcastArea: BroadcastArea) => ({
|
||||
label: broadcastArea.getName(),
|
||||
query: `broadcast_area:${broadcastArea.code}`
|
||||
}))
|
||||
.all()
|
||||
},
|
||||
{
|
||||
name: 'timezones',
|
||||
type: 'link[]',
|
||||
value: this.getTimezoneIds()
|
||||
.map((id: string) => ({
|
||||
label: id,
|
||||
query: `timezone:${id}`
|
||||
}))
|
||||
.all()
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
type: 'link[]',
|
||||
value: this.getLanguages()
|
||||
.map((language: Language) => ({
|
||||
label: language.name,
|
||||
query: `language:${language.code}`
|
||||
}))
|
||||
.all()
|
||||
},
|
||||
{
|
||||
name: 'categories',
|
||||
type: 'link[]',
|
||||
value: this.categories
|
||||
.map((category: Category) => ({
|
||||
label: category.name,
|
||||
query: `category:${category.id}`
|
||||
}))
|
||||
.all()
|
||||
},
|
||||
{
|
||||
name: 'is_nsfw',
|
||||
type: 'link',
|
||||
value: { label: this.isNSFW.toString(), query: `is_nsfw:${this.isNSFW.toString()}` }
|
||||
},
|
||||
{
|
||||
name: 'video_formats',
|
||||
type: 'link[]',
|
||||
value: this.getVideoFormats()
|
||||
.map((format: string) => ({
|
||||
label: format,
|
||||
query: `video_format:${format}`
|
||||
}))
|
||||
.all()
|
||||
},
|
||||
{
|
||||
name: 'launched',
|
||||
type: 'string',
|
||||
value: this.launchedDate ? this.launchedDate.format('D MMMM YYYY') : null,
|
||||
title: this.launchedDateString
|
||||
},
|
||||
{
|
||||
name: 'closed',
|
||||
type: 'string',
|
||||
value: this.closedDate ? this.closedDate.format('D MMMM YYYY') : null,
|
||||
title: this.closedDateString
|
||||
},
|
||||
{
|
||||
name: 'replaced_by',
|
||||
type: 'link',
|
||||
value: this.replacedByStreamId
|
||||
? {
|
||||
label: this.replacedByStreamId,
|
||||
query: `id:${this.replacedByChannelId.replace('.', '\\.')}`
|
||||
}
|
||||
: null
|
||||
},
|
||||
{
|
||||
name: 'website',
|
||||
type: 'external_link',
|
||||
value: this.websiteUrl
|
||||
? { href: this.websiteUrl, title: this.websiteUrl, label: this.websiteUrl }
|
||||
: null
|
||||
}
|
||||
].filter((field: HTMLPreviewField) =>
|
||||
Array.isArray(field.value) ? field.value.length : field.value
|
||||
)
|
||||
}
|
||||
|
||||
getStructuredData() {
|
||||
return {
|
||||
'@context': 'https://schema.org/',
|
||||
'@type': 'TelevisionChannel',
|
||||
image: this.logoUrl,
|
||||
identifier: this.id,
|
||||
name: this.name,
|
||||
alternateName: this.altNames.map((name: string) => ({ '@value': name })),
|
||||
genre: this.categories.map((category: Category) => ({ '@value': category.name })),
|
||||
sameAs: this.websiteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(value: string) {
|
||||
value = value.includes(' ') ? `"${value}"` : value
|
||||
|
||||
return encodeURIComponent(value)
|
||||
}
|