This commit is contained in:
Arhey 2022-04-07 15:13:58 +03:00
parent 465c95db8a
commit 7fc7d5c0c2
35 changed files with 3806 additions and 1266 deletions

View file

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
import{S as h,i as w,s as y,e as E,t as v,c as d,a as b,h as P,d as o,g as u,I as R,j as N,k as S,l as C,m as j,K as H}from"./chunks/vendor-dedc54d0.js";function I(r){let l,t=r[1].frame+"",a;return{c(){l=E("pre"),a=v(t)},l(f){l=d(f,"PRE",{});var s=b(l);a=P(s,t),s.forEach(o)},m(f,s){u(f,l,s),R(l,a)},p(f,s){s&2&&t!==(t=f[1].frame+"")&&N(a,t)},d(f){f&&o(l)}}}function K(r){let l,t=r[1].stack+"",a;return{c(){l=E("pre"),a=v(t)},l(f){l=d(f,"PRE",{});var s=b(l);a=P(s,t),s.forEach(o)},m(f,s){u(f,l,s),R(l,a)},p(f,s){s&2&&t!==(t=f[1].stack+"")&&N(a,t)},d(f){f&&o(l)}}}function z(r){let l,t,a,f,s=r[1].message+"",c,k,n,p,i=r[1].frame&&I(r),_=r[1].stack&&K(r);return{c(){l=E("h1"),t=v(r[0]),a=S(),f=E("pre"),c=v(s),k=S(),i&&i.c(),n=S(),_&&_.c(),p=C()},l(e){l=d(e,"H1",{});var m=b(l);t=P(m,r[0]),m.forEach(o),a=j(e),f=d(e,"PRE",{});var q=b(f);c=P(q,s),q.forEach(o),k=j(e),i&&i.l(e),n=j(e),_&&_.l(e),p=C()},m(e,m){u(e,l,m),R(l,t),u(e,a,m),u(e,f,m),R(f,c),u(e,k,m),i&&i.m(e,m),u(e,n,m),_&&_.m(e,m),u(e,p,m)},p(e,[m]){m&1&&N(t,e[0]),m&2&&s!==(s=e[1].message+"")&&N(c,s),e[1].frame?i?i.p(e,m):(i=I(e),i.c(),i.m(n.parentNode,n)):i&&(i.d(1),i=null),e[1].stack?_?_.p(e,m):(_=K(e),_.c(),_.m(p.parentNode,p)):_&&(_.d(1),_=null)},i:H,o:H,d(e){e&&o(l),e&&o(a),e&&o(f),e&&o(k),i&&i.d(e),e&&o(n),_&&_.d(e),e&&o(p)}}}function D({error:r,status:l}){return{props:{error:r,status:l}}}function A(r,l,t){let{status:a}=l,{error:f}=l;return r.$$set=s=>{"status"in s&&t(0,a=s.status),"error"in s&&t(1,f=s.error)},[a,f]}class F extends h{constructor(l){super();w(this,l,A,z,y,{status:0,error:1})}}export{F as default,D as load};

View file

@ -1,45 +0,0 @@
{
".svelte-kit/runtime/client/start.js": {
"file": "start-d23075dc.js",
"src": ".svelte-kit/runtime/client/start.js",
"isEntry": true,
"imports": [
"_vendor-dedc54d0.js"
],
"dynamicImports": [
"src/routes/__layout.svelte",
".svelte-kit/runtime/components/error.svelte",
"src/routes/index.svelte"
]
},
"src/routes/__layout.svelte": {
"file": "pages/__layout.svelte-6869b5a4.js",
"src": "src/routes/__layout.svelte",
"isEntry": true,
"isDynamicEntry": true,
"imports": [
"_vendor-dedc54d0.js"
]
},
".svelte-kit/runtime/components/error.svelte": {
"file": "error.svelte-aa63ee3d.js",
"src": ".svelte-kit/runtime/components/error.svelte",
"isEntry": true,
"isDynamicEntry": true,
"imports": [
"_vendor-dedc54d0.js"
]
},
"src/routes/index.svelte": {
"file": "pages/index.svelte-1502efd3.js",
"src": "src/routes/index.svelte",
"isEntry": true,
"isDynamicEntry": true,
"imports": [
"_vendor-dedc54d0.js"
]
},
"_vendor-dedc54d0.js": {
"file": "chunks/vendor-dedc54d0.js"
}
}

View file

@ -1 +0,0 @@
import{S as Q,i as W,s as Z,F as x,G as ee,e as v,c as _,a as r,d as s,H as se,b as o,f as w,g as y,I as d,J as j,K as te,L as U,k as P,M as X,m as q,N as le,O as ae,P as oe,q as re,o as ie}from"../chunks/vendor-dedc54d0.js";function Y(b){let t,m,p,a,h;return{c(){t=v("button"),m=v("span"),p=v("ion-icon"),this.h()},l(i){t=_(i,"BUTTON",{class:!0,style:!0});var n=r(t);m=_(n,"SPAN",{class:!0});var g=r(m);p=_(g,"ION-ICON",{name:!0}),r(p).forEach(s),g.forEach(s),n.forEach(s),this.h()},h(){se(p,"name","arrow-up-outline"),o(m,"class","icon is-small"),o(t,"class","button level-item is-hidden-mobile"),w(t,"pointer-events","auto")},m(i,n){y(i,t,n),d(t,m),d(m,p),a||(h=j(t,"click",ce),a=!0)},p:te,d(i){i&&s(t),a=!1,h()}}}function ne(b){let t=!1,m=()=>{t=!1},p,a,h,i,n,g,T,D,N,$,c,O,E,V,S,I,k,A,F;x(b[3]);const B=b[2].default,u=ee(B,b,b[1],null);let l=b[0]>100&&Y();return{c(){a=v("div"),h=v("div"),i=v("div"),n=v("a"),g=v("span"),T=U("svg"),D=U("path"),N=P(),u&&u.c(),$=P(),c=v("footer"),O=v("div"),E=v("div"),V=v("div"),S=P(),I=v("div"),l&&l.c(),this.h()},l(e){a=_(e,"DIV",{class:!0,style:!0});var f=r(a);h=_(f,"DIV",{class:!0});var M=r(h);i=_(M,"DIV",{class:!0});var z=r(i);n=_(z,"A",{href:!0});var G=r(n);g=_(G,"SPAN",{class:!0});var H=r(g);T=X(H,"svg",{xmlns:!0,viewBox:!0});var J=r(T);D=X(J,"path",{d:!0}),r(D).forEach(s),J.forEach(s),H.forEach(s),G.forEach(s),z.forEach(s),M.forEach(s),f.forEach(s),N=q(e),u&&u.l(e),$=q(e),c=_(e,"FOOTER",{class:!0,style:!0});var K=r(c);O=_(K,"DIV",{class:!0});var L=r(O);E=_(L,"DIV",{class:!0});var C=r(E);V=_(C,"DIV",{class:!0}),r(V).forEach(s),S=q(C),I=_(C,"DIV",{class:!0});var R=r(I);l&&l.l(R),R.forEach(s),C.forEach(s),L.forEach(s),K.forEach(s),this.h()},h(){o(D,"d","M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z"),o(T,"xmlns","http://www.w3.org/2000/svg"),o(T,"viewBox","0 0 512 512"),o(g,"class","icon"),o(n,"href","https://github.com/iptv-org/api"),o(i,"class","navbar-item"),o(h,"class","navbar-end"),o(a,"class","navbar"),w(a,"background-color","transparent"),o(V,"class","level-left"),o(I,"class","level-right"),o(E,"class","level"),o(O,"class","content"),o(c,"class","footer"),w(c,"background-color","transparent"),w(c,"position","fixed"),w(c,"bottom","0"),w(c,"width","100%"),w(c,"padding","3rem 1.5rem"),w(c,"pointer-events","none")},m(e,f){y(e,a,f),d(a,h),d(h,i),d(i,n),d(n,g),d(g,T),d(T,D),y(e,N,f),u&&u.m(e,f),y(e,$,f),y(e,c,f),d(c,O),d(O,E),d(E,V),d(E,S),d(E,I),l&&l.m(I,null),k=!0,A||(F=j(window,"scroll",()=>{t=!0,clearTimeout(p),p=setTimeout(m,100),b[3]()}),A=!0)},p(e,[f]){f&1&&!t&&(t=!0,clearTimeout(p),scrollTo(window.pageXOffset,e[0]),p=setTimeout(m,100)),u&&u.p&&(!k||f&2)&&le(u,B,e,e[1],k?oe(B,e[1],f,null):ae(e[1]),null),e[0]>100?l?l.p(e,f):(l=Y(),l.c(),l.m(I,null)):l&&(l.d(1),l=null)},i(e){k||(re(u,e),k=!0)},o(e){ie(u,e),k=!1},d(e){e&&s(a),e&&s(N),u&&u.d(e),e&&s($),e&&s(c),l&&l.d(),A=!1,F()}}}function ce(b){document.body.scrollTop=0,document.documentElement.scrollTop=0}function ue(b,t,m){let{$$slots:p={},$$scope:a}=t,h=0;function i(){m(0,h=window.pageYOffset)}return b.$$set=n=>{"$$scope"in n&&m(1,a=n.$$scope)},[h,a,p,i]}class de extends Q{constructor(t){super();W(this,t,ue,ne,Z,{})}}export{de as default};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{"version":"1645521886104"}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -1,58 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="description" content="" />
<link rel="icon" href="./favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css" />
<script
type="module"
src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"
></script>
<meta http-equiv="content-security-policy" content="">
<link rel="modulepreload" href="/_app/start-d23075dc.js">
<link rel="modulepreload" href="/_app/chunks/vendor-dedc54d0.js">
<link rel="modulepreload" href="/_app/pages/__layout.svelte-6869b5a4.js">
<link rel="modulepreload" href="/_app/pages/index.svelte-1502efd3.js">
</head>
<body style="background-color: #f6f8fa; min-height: 100vh">
<div>
<div class="navbar" style="background-color: transparent"><div class="navbar-end"><div class="navbar-item"><a href="https://github.com/iptv-org/api"><span class="icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z"></path></svg></span></a></div></div></div>
<div class="section"><div class="container"><div class="columns is-centered"><div class="column is-9"><form class="mb-5"><div class="field-body"><div class="field is-expanded"><div class="field has-addons"><div class="control is-expanded"><input class="input" type="search" placeholder="Search by channel name..." value=""></div>
<div class="control"><button class="button is-info" type="submit"><span class="icon is-small is-right"><svg xmlns="http://www.w3.org/2000/svg" style="width: 1.25rem; height: 1.25rem" viewBox="0 0 512 512"><path fill="#ffffff" d="M456.69 421.39L362.6 327.3a173.81 173.81 0 0034.84-104.58C397.44 126.38 319.06 48 222.72 48S48 126.38 48 222.72s78.38 174.72 174.72 174.72A173.81 173.81 0 00327.3 362.6l94.09 94.09a25 25 0 0035.3-35.3zM97.92 222.72a124.8 124.8 0 11124.8 124.8 124.95 124.95 0 01-124.8-124.8z"></path></svg></span></button></div></div>
</div></div></form>
<div class="level"><div class="level-item">Loading...</div></div> </div></div></div></div>
<footer class="footer" style="background-color: transparent; position: fixed; bottom: 0; width: 100%; padding: 3rem 1.5rem; pointer-events: none; "><div class="content"><div class="level"><div class="level-left"></div>
<div class="level-right"></div></div></div></footer>
<script type="module" data-hydrate="299air">
import { start } from "/_app/start-d23075dc.js";
start({
target: document.querySelector('[data-hydrate="299air"]').parentNode,
paths: {"base":"","assets":""},
session: {},
route: true,
spa: false,
trailing_slash: "never",
hydrate: {
status: 200,
error: null,
nodes: [
import("/_app/pages/__layout.svelte-6869b5a4.js"),
import("/_app/pages/index.svelte-1502efd3.js")
],
params: {}
}
});
</script></div>
</body>
</html>

View file

@ -6,5 +6,6 @@
"$lib/*": ["src/lib/*"] "$lib/*": ["src/lib/*"]
} }
}, },
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"],
"extends": "./.svelte-kit/tsconfig.json"
} }

3298
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "site", "name": "iptv-org",
"version": "0.0.1", "private": true,
"scripts": { "scripts": {
"dev": "svelte-kit dev", "dev": "svelte-kit dev",
"build": "svelte-kit build", "build": "svelte-kit build",
@ -11,9 +11,17 @@
"@sveltejs/adapter-auto": "next", "@sveltejs/adapter-auto": "next",
"@sveltejs/adapter-static": "^1.0.0-next.28", "@sveltejs/adapter-static": "^1.0.0-next.28",
"@sveltejs/kit": "next", "@sveltejs/kit": "next",
"@zerodevx/svelte-json-view": "^0.2.0",
"autoprefixer": "^10.4.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"postcss": "^8.4.6",
"prettier-plugin-svelte": "^2.6.0", "prettier-plugin-svelte": "^2.6.0",
"svelte": "^3.44.0" "svelte": "^3.44.0",
"svelte-clipboard": "^1.0.0",
"svelte-infinite-loading": "^1.3.8",
"svelte-simple-modal": "^1.3.1",
"tailwindcss": "^3.0.23",
"transliteration": "^2.2.0"
}, },
"type": "module" "type": "module"
} }

3
src/app.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -2,17 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="description" content="" />
<link rel="icon" href="%svelte.assets%/favicon.png" /> <link rel="icon" href="%svelte.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css" />
<script
type="module"
src="https://unpkg.com/ionicons@5.5.2/dist/ionicons/ionicons.esm.js"
></script>
%svelte.head% %svelte.head%
</head> </head>
<body style="background-color: #f6f8fa; min-height: 100vh"> <body>
<div>%svelte.body%</div> %svelte.body%
</body> </body>
</html> </html>

View file

@ -0,0 +1,43 @@
<script>
import ChannelItem from './ChannelItem.svelte'
export let channels = []
</script>
<div class="flex flex-col">
<div class="overflow-x-auto">
<div class="inline-block min-w-full align-middle">
<div class="overflow-hidden">
<table
class="min-w-full divide-y divide-gray-200 dark:divide-gray-700 table-fixed md:w-[62rem]"
>
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="min-w-[10rem] md:w-[12rem]"></th>
<th
scope="col"
class="py-3 px-2 text-xs font-semibold tracking-wider text-left text-gray-400 uppercase dark:text-gray-400 min-w-[14rem] md:w-[19rem]"
>
Name
</th>
<th
scope="col"
class="py-3 px-2 text-xs font-semibold tracking-wider text-left text-gray-400 uppercase dark:text-gray-400 min-w-[12rem] md:w-[18rem]"
>
TVG-ID
</th>
<th scope="col">
<span class="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800">
{#each channels as channel}
<ChannelItem bind:channel="{channel}" />
{/each}
</tbody>
</table>
</div>
</div>
</div>
</div>

View file

@ -1,30 +1,121 @@
<script> <script>
import { getContext } from 'svelte'
import StreamsPopup from './StreamsPopup.svelte'
import GuidesPopup from './GuidesPopup.svelte'
import ChannelPopup from './ChannelPopup.svelte'
export let channel export let channel
const guides = channel._guides
const streams = channel._streams
const { open } = getContext('simple-modal')
const showGuides = () =>
open(
GuidesPopup,
{ guides, title: channel.name },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
const showStreams = () =>
open(
StreamsPopup,
{ streams, title: channel.name },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
const showChannelData = () => {
open(
ChannelPopup,
{ channel },
{ transitionBgProps: { duration: 0 }, transitionWindowProps: { duration: 0 } }
)
}
</script> </script>
<tr> <tr
<td class="is-vcentered" style="min-width: 150px; text-align: center"> 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"
{#if channel && channel.logo} >
<td class="pl-2 pr-4 md:pr-7">
<div class="inline-flex w-full align-middle justify-center whitespace-nowrap overflow-hidden">
{#if channel.logo}
<img <img
class="block align-middle mx-auto max-w-[6rem] max-h-[3rem] text-sm text-gray-400 dark:text-gray-600 cursor-default"
loading="lazy" loading="lazy"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
src="{channel.logo}" src="{channel.logo}"
alt="{channel.name}" alt="{channel.name}"
style="max-width: 100px; max-height: 50px; vertical-align: middle"
/> />
{/if} {/if}
</div>
</td> </td>
<td class="is-vcentered" nowrap> <td class="px-2">
<p>{channel.name}</p> <div>
<a
on:click|preventDefault="{showChannelData}"
href="/"
rel="nofollow"
role="button"
tabindex="0"
class="text-left font-normal text-gray-600 dark:text-white hover:underline hover:text-blue-500"
>
{channel.name}
</a>
</div>
</td> </td>
<td class="is-vcentered" nowrap> <td class="px-2">
<code style="user-select: all">{channel.id}</code> <div>
<code
class="break-words text-sm text-gray-500 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>
</td> </td>
<td class="is-vcentered"> <td class="pl-2 pr-5">
{#each channel.guides as guide} <div class="text-right flex justify-end space-x-3 items-center">
<p> {#if streams.length}
<code style="white-space: nowrap; user-select: all">{guide.url}</code> <button
</p> on:click="{showStreams}"
{/each} 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
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="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"
></path>
</svg>
<div class="font-semibold">{streams.length}</div>
<div>streams</div>
</button>
{/if}{#if guides.length}
<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"
>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div class="font-semibold">{guides.length}</div>
<div>guides</div>
</button>
{/if}
</div>
</td> </td>
</tr> </tr>

View file

@ -0,0 +1,90 @@
<script>
import JsonDataViewer from './JsonDataViewer.svelte'
import HTMLPreview from './HTMLPreview.svelte'
import { getContext } from 'svelte'
const { close } = getContext('simple-modal')
export let channel
let view = 'html'
function switchView(value) {
view = value
}
const closePopup = () => {
close()
}
</script>
<style>
.active {
background-color: #f3f4f6;
color: #111828;
}
</style>
<div class="relative px-2 py-[4rem] flex justify-center" on:click|self="{closePopup}">
<div class="relative bg-white rounded-md shadow dark:bg-gray-800 w-full max-w-4xl">
<div
class="flex justify-between items-center py-4 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
>
<div class="w-1/3 overflow-hidden">
<h3 class="text-l font-medium text-gray-900 dark:text-white">{channel.name}</h3>
</div>
<div class="inline-flex justify-center w-1/3">
<div class="inline-flex rounded-md" role="group">
<button
type="button"
area-selected="{view === 'html'}"
on:click="{() => switchView('html')}"
class:active="{view === 'html'}"
class="py-2 px-4 text-xs font-medium text-gray-900 bg-white rounded-l-lg border border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:bg-transparent dark:text-white dark:hover:text-white dark:hover:bg-gray-600"
>
HTML
</button>
<button
type="button"
area-selected="{view === 'html'}"
on:click="{() => switchView('json')}"
class:active="{view === 'json'}"
class="py-2 px-4 text-xs font-medium text-gray-900 bg-white border-t border-b border-r rounded-r-lg border-gray-200 hover:bg-gray-100 dark:bg-transparent dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-600"
>
JSON
</button>
</div>
</div>
<div class="inline-flex w-1/3 justify-end">
<button
on:click="{closePopup}"
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"
>
<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>
</div>
</div>
<div class="overflow-y-scroll overflow-x-hidden w-full">
{#if view === 'json'}
<div class="pb-8 px-8 pt-6">
<div class="flex p-4 bg-gray-50 dark:bg-gray-700 rounded-md w-full">
<JsonDataViewer data="{channel._raw}" />
</div>
</div>
{:else if view === 'html'}
<HTMLPreview data="{channel}" close="{closePopup}" />
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,57 @@
<script>
import Clipboard from 'svelte-clipboard'
export let text
let showTooltip = false
function onSuccess() {
showTooltip = true
setTimeout(() => {
showTooltip = false
}, 2000)
}
</script>
<style>
.tooltip::after {
content: '';
position: absolute;
left: 100%;
top: 50%;
border-width: 7px;
border-style: solid;
transform: translate3d(0, -7px, 0px);
border-color: transparent transparent transparent black;
}
</style>
<Clipboard text="{text}" on:copy="{onSuccess}" let:copy>
<button
type="button"
on:click="{copy}"
class="relative flex items-center p-1 text-xs text-gray-500 dark:text-gray-100"
>
<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>
</button>
</Clipboard>

View file

@ -1,65 +1,52 @@
<script> <script>
import ChannelItem from './ChannelItem.svelte' import ChannelGrid from './ChannelGrid.svelte'
export let country export let country
export let channels = [] export let channels = []
export let normQuery export let hasQuery
$: expanded = country.expanded || (channels && channels.length > 0 && hasQuery)
function onExpand() { function onExpand() {
country.expanded = !country.expanded country.expanded = !country.expanded
} }
</script> </script>
{#if channels && channels.length > 0} <div class="mb-3">
<div class="card mb-3 is-shadowless" style="border: 1px solid #dbdbdb"> <h2 id="accordion-heading-{country.code}">
<div class="card-header is-shadowless is-clickable" on:click="{onExpand}"> <button
<span class="card-header-title">{country.flag}&nbsp;{country.name}</span> on:click="{onExpand}"
<button class="card-header-icon" aria-label="more options"> type="button"
<span class="icon"> 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"
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"> class:rounded-t-md="{expanded}"
{#if !country.expanded} class:rounded-md="{!expanded}"
class:border-b-0="{expanded}"
aria-expanded="{expanded}"
aria-controls="accordion-body-{country.code}"
>
<span>{country.flag}&nbsp;{country.name}</span>
<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 <path
fill="none" fill-rule="evenodd"
stroke="currentColor" 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"
stroke-linecap="round" clip-rule="evenodd"
stroke-linejoin="round" ></path>
stroke-width="48"
d="M112 184l144 144 144-144"
/>
{/if} {#if country.expanded}
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="48"
d="M112 328l144-144 144 144"
/>
{/if}
</svg> </svg>
</span>
</button> </button>
</div> </h2>
{#if country.expanded || (channels && channels.length > 0 && normQuery.length)} {#if expanded}
<div class="card-content"> <div id="accordion-body-{country.code}" aria-labelledby="accordion-heading-{country.code}">
<div class="table-container"> <div
<table class="table" style="min-width: 100%"> class="border border-gray-200 dark:border-gray-700 dark:bg-gray-900 rounded-b-md overflow-hidden"
<thead> >
<tr> <ChannelGrid bind:channels="{channels}" />
<th></th>
<th>Name</th>
<th>TVG-ID</th>
<th>EPG</th>
</tr>
</thead>
<tbody>
{#each channels as channel}
<ChannelItem bind:channel="{channel}"></ChannelItem>
{/each}
</tbody>
</table>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
{/if}

View file

@ -0,0 +1,73 @@
<script>
import CopyToClipboard from './CopyToClipboard.svelte'
import JsonDataViewer from './JsonDataViewer.svelte'
export let guide
let expanded = false
</script>
<div
class="w-full bg-gray-100 dark:bg-gray-700 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}"
>
<div class="flex space-x-3 items-center max-w-[90%]">
<button
class="w-4 h-4 flex justify-center align-middle text-gray-500 hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-600 shrink-0"
on:click="{() => {expanded = !expanded}}"
>
<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>
<a
class="whitespace-nowrap text-sm text-gray-600 dark:text-gray-100 hover:text-blue-500 hover:underline inline-flex align-middle"
href="{guide.url}"
title="{guide.url}"
target="_blank"
>
<span class="truncate max-w-[30rem]">{guide.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"
>
<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="{guide.url}" />
</div>
</div>
{#if expanded}
<div class="w-full flex px-2 py-4">
<JsonDataViewer data="{guide}" />
</div>
{/if}
</div>

View file

@ -0,0 +1,62 @@
<script>
import GuideItem from './GuideItem.svelte'
import { getContext } from 'svelte'
const { close } = getContext('simple-modal')
export let guides = []
export let title = 'Guides'
</script>
<div class="relative px-2 py-[9rem] flex justify-center" 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-4 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
>
<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 rounded-full"
>
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg> </span
>{title}
</h3>
<button
on:click="{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"
>
<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>
</div>
<div class="overflow-y-scroll overflow-x-hidden w-full">
<div class="p-6 space-y-2">
{#each guides as guide}
<GuideItem guide="{guide}" />
{/each}
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,102 @@
<script>
import { search, query, hasQuery } from '../store.js'
export let data
export let close
const fieldset = [
{ name: 'logo', type: 'image', value: data.logo },
{ name: 'name', type: 'string', value: data.name },
{ name: 'native_name', type: 'string', value: data.native_name },
{ name: 'network', type: 'link', value: data.network },
{ name: 'country', type: 'link', value: data.country.name },
{ name: 'subdivision', type: 'link', value: data.subdivision ? data.subdivision.name : null },
{ name: 'city', type: 'link', value: data.city },
{ name: 'broadcast_area', type: 'link[]', value: data.broadcast_area.map(v => v.name) },
{ name: 'languages', type: 'link[]', value: data.languages.map(v => v.name) },
{ name: 'categories', type: 'link[]', value: data.categories.map(v => v.name) },
{ name: 'is_nsfw', type: 'link', value: data.is_nsfw.toString() },
{ name: 'website', type: 'external_link', value: data.website }
].filter(f => (Array.isArray(f.value) ? f.value.length : f.value))
function searchBy(name, value) {
value = value.includes(' ') ? `"${value}"` : value
const q = `${name}:${value}`
query.set(q)
hasQuery.set(true)
search(q)
close()
}
</script>
<div class="pb-8 px-8 pt-6 dark:text-white">
<div class="flex p-4 w-full">
<table class="table-fixed w-full">
<tbody>
{#each fieldset as field}
<tr>
<td class="align-top w-[11rem]">
<div class="flex px-4 py-1 text-sm text-gray-400 whitespace-nowrap dark:text-gray-400">
{field.name}
</div>
</td>
<td class="align-top">
<div class="flex px-4 py-1 text-sm text-gray-700 dark:text-gray-100 flex-wrap">
{#if field.type === 'image'}
<img
src="{field.value}"
alt="{field.name}"
loading="lazy"
referrerpolicy="no-referrer"
class="border rounded-sm overflow-hidden border-gray-200 bg-[#e6e6e6]"
/>
{:else if field.type === 'link'}
<button
on:click="{() => searchBy(field.name, field.value)}"
class="underline hover:text-blue-500"
>
{field.value}
</button>
{:else if field.type === 'link[]'} {#each field.value as value, i} {#if i > 0}<span
>,&nbsp;
</span>
{/if}
<button
on:click="{() => searchBy(field.name, value)}"
class="underline hover:text-blue-500"
>
{value}
</button>
{/each} {:else if field.type === 'external_link'}
<a
href="{field.value}"
class="underline hover:text-blue-500 inline-flex align-middle"
target="_blank"
rel="noopener noreferrer"
>{field.value}<span
class="inline-flex items-center pl-1 text-sm font-semibold text-gray-400 rounded-full"
>
<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> </span
></a>
{:else} {field.value} {/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View file

@ -0,0 +1,80 @@
<script>
import { onMount } from 'svelte'
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
}
})
</script>
<style>
:global(.value .val),
:global(.value .key) {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 1em;
}
:global(.dark .value) {
--leafDefaultColor: white;
--leafStringColor: white;
--leafNumberColor: white;
--leafBooleanColor: white;
--nodeColor: white;
}
:global(.value) {
--nodeBorderLeft: 1px dotted #9ca3b0;
--leafDefaultColor: #525a69;
--leafStringColor: #525a69;
--leafNumberColor: #525a69;
--leafBooleanColor: #525a69;
--nodeColor: #525a69;
}
:global(.value .key),
:global(.value .comma) {
color: #9ca3b0;
}
</style>
<table class="table-fixed w-full dark:text-white">
<tbody>
{#each fieldset as field}
<tr>
<td
class="w-[6rem] md:w-[11rem] px-4 py-1 text-sm text-gray-400 whitespace-nowrap dark:text-gray-400 align-top"
>
{field.name}
</td>
<td class="px-4 py-1 text-sm text-gray-600 dark:text-gray-100 align-top value break-words">
{#if Array.isArray(field.value) && field.value.length}
<JsonView json="{field.value}" />
{:else}
<code>{JSON.stringify(field.value)}</code>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>

View file

@ -0,0 +1,194 @@
<script>
import { query, hasQuery, search } from '../store.js'
import { onMount } from 'svelte'
import SearchFieldMini from './SearchFieldMini.svelte'
export let withSearch = false
let dark = false
function toggleDarkMode() {
let mode = localStorage.theme || 'light'
if (mode === 'dark' || window.matchMedia('(prefers-color-scheme: dark)').matches) {
dark = false
document.documentElement.classList.remove('dark')
localStorage.theme = 'light'
} else {
dark = true
document.documentElement.classList.add('dark')
localStorage.theme = 'dark'
}
}
function reset() {
document.body.scrollIntoView()
query.set('')
hasQuery.set(false)
search('')
}
onMount(() => {
let mode = localStorage.theme || 'light'
if (mode === 'dark' || window.matchMedia('(prefers-color-scheme: dark)').matches) {
dark = true
} else {
dark = false
}
})
</script>
<nav
class="bg-white border-b border-gray-200 px-2 sm:px-4 py-2.5 dark:border-gray-600 dark:bg-gray-800"
>
<div class="container flex justify-between items-center max-w-6xl mx-auto px-4 sm:px-2">
<div class="flex flex-start items-center basis-[24rem] 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>
{#if withSearch}
<SearchFieldMini />
{/if}
</div>
<div class="flex flex-end items-center">
<div class="md:inline-block md:w-auto pr-4">
<ul class="hidden lg:flex space-x-7">
<li>
<a
href="https://github.com/iptv-org/iptv"
class="block py-2 pr-4 pl-3 text-sm text-gray-700 border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
target="_blank"
>Playlists<span
class="inline-flex items-center p-1 mr-2 text-sm font-semibold text-gray-400 rounded-full"
>
<svg
class="w-3 h-3 fill-gray-400"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
>
<path d="M0 0h24v24H0V0z" fill="none"></path>
<path d="M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z"></path>
</svg> </span
></a>
</li>
<li>
<a
href="https://github.com/iptv-org/epg"
class="block py-2 pr-4 pl-3 text-sm text-gray-700 border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
target="_blank"
>EPG<span
class="inline-flex items-center p-1 mr-2 text-sm font-semibold text-gray-400 rounded-full"
>
<svg
class="w-3 h-3 fill-gray-400"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
>
<path d="M0 0h24v24H0V0z" fill="none"></path>
<path d="M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z"></path>
</svg> </span
></a>
</li>
<li>
<a
href="https://github.com/iptv-org/database"
class="block py-2 pr-4 pl-3 text-sm text-gray-700 border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
target="_blank"
>Database<span
class="inline-flex items-center p-1 mr-2 text-sm font-semibold text-gray-400 rounded-full"
>
<svg
class="w-3 h-3 fill-gray-400"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
>
<path d="M0 0h24v24H0V0z" fill="none"></path>
<path d="M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z"></path>
</svg> </span
></a>
</li>
<li>
<a
href="https://github.com/iptv-org/api"
class="block py-2 pr-4 pl-3 text-sm text-gray-700 border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700"
target="_blank"
>API<span
class="inline-flex items-center p-1 mr-2 text-sm font-semibold text-gray-400 rounded-full"
>
<svg
class="w-3 h-3 fill-gray-400"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
focusable="false"
viewBox="0 0 24 24"
>
<path d="M0 0h24v24H0V0z" fill="none"></path>
<path d="M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5H9z"></path>
</svg> </span
></a>
</li>
</ul>
</div>
<button
type="button"
on:click="{toggleDarkMode}"
class="text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-sm p-2.5"
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>
</button>
<a
href="https://github.com/iptv-org/"
class="inline-flex text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full text-sm p-2.5 ml-1"
target="_blank"
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>
</a>
</div>
</div>
</nav>

View file

@ -0,0 +1,42 @@
<script>
import { query, search } from '../store.js'
export let found = 0
export let isLoading = true
</script>
<form class="mb-5" on:submit|preventDefault="{search($query)}">
<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">
<svg
class="w-5 h-5 text-gray-500 dark:text-gray-400"
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"
/>
</div>
<p class="mt-2">
<span class="inline-flex text-sm text-gray-500 dark:text-gray-400 font-mono"
>Found&nbsp;
<span class:animate-spin="{isLoading}">{ !isLoading ? found.toLocaleString() : '/' }</span>
&nbsp;channels</span
>
</p>
</div>
</form>

View file

@ -0,0 +1,32 @@
<script>
import { query, search } from '../store.js'
</script>
<form on:submit|preventDefault="{search($query)}" 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">
<svg
class="w-4 h-4 text-gray-500 dark:text-gray-400"
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-9 p-1.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white"
placeholder="Search"
/>
</div>
</div>
</form>

View file

@ -0,0 +1,84 @@
<script>
import CopyToClipboard from './CopyToClipboard.svelte'
import JsonDataViewer from './JsonDataViewer.svelte'
export let stream
let expanded = false
</script>
<div
class="w-full bg-gray-100 dark:bg-gray-700 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}"
>
<div class="flex space-x-3 items-center max-w-[90%]">
<button
class="w-4 h-4 flex justify-center align-middle text-gray-500 hover:text-blue-600 dark:text-gray-100 dark:hover:text-blue-600 shrink-0"
on:click="{() => {expanded = !expanded}}"
>
<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>
<svg
class="w-2 h-2 flex shrink-0"
viewBox="0 0 100 100"
xmlns="http://www.w3.org/2000/svg"
class:fill-green-500="{stream.status === 'online'}"
class:fill-yellow-500="{['blocked', 'timeout'].includes(stream.status)}"
class:fill-red-500="{stream.status === 'error'}"
>
<circle cx="50" cy="50" r="50" />
</svg>
<a
class="whitespace-nowrap text-sm text-gray-600 dark:text-gray-100 hover:text-blue-500 hover:underline inline-flex align-middle"
href="{stream.url}"
title="{stream.url}"
target="_blank"
rel="noopener noreferrer"
>
<span class="truncate max-w-[30rem]">{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"
>
<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}" />
</div>
</div>
{#if expanded}
<div class="w-full flex px-2 py-4">
<JsonDataViewer data="{stream}" />
</div>
{/if}
</div>

View file

@ -0,0 +1,62 @@
<script>
import StreamItem from './StreamItem.svelte'
import { getContext } from 'svelte'
const { close } = getContext('simple-modal')
export let streams = []
export let title = 'Streams'
</script>
<div class="relative px-2 py-[10rem] flex justify-center" 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-4 pl-5 pr-4 rounded-t border-b dark:border-gray-700"
>
<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 rounded-full"
>
<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="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"
></path>
</svg> </span
>{title}
</h3>
<button
on:click="{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"
>
<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>
</div>
<div class="overflow-y-scroll overflow-x-hidden w-full">
<div class="p-6 space-y-2">
{#each streams as stream}
<StreamItem stream="{stream}" />
{/each}
</div>
</div>
</div>
</div>

View file

@ -1,59 +1,41 @@
<script> <script>
let scrollTop = 0 import '../app.css'
import NavBar from '../components/NavBar.svelte'
import Modal from 'svelte-simple-modal'
function scrollToTop(argument) { let scrollTop = 0
document.body.scrollTop = 0
document.documentElement.scrollTop = 0
}
</script> </script>
<svelte:window bind:scrollY="{scrollTop}" /> <svelte:window bind:scrollY="{scrollTop}" />
<svelte:head>
<script>
if (document) {
let mode = localStorage.theme || 'light'
if (mode === 'dark' || window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark')
localStorage.theme = 'dark'
} else {
document.documentElement.classList.remove('dark')
localStorage.theme = 'light'
}
}
</script>
</svelte:head>
<div class="navbar" style="background-color: transparent"> <header
<div class="navbar-end"> class:absolute="{scrollTop <= 150}"
<div class="navbar-item"> class:fixed="{scrollTop > 150}"
<a href="https://github.com/iptv-org/api"> class="z-40 w-full min-w-[360px]"
<span class="icon"> style="top: {scrollTop > 150 && scrollTop <= 210 ? scrollTop-210: 0}px"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
d="M256 32C132.3 32 32 134.9 32 261.7c0 101.5 64.2 187.5 153.2 217.9a17.56 17.56 0 003.8.4c8.3 0 11.5-6.1 11.5-11.4 0-5.5-.2-19.9-.3-39.1a102.4 102.4 0 01-22.6 2.7c-43.1 0-52.9-33.5-52.9-33.5-10.2-26.5-24.9-33.6-24.9-33.6-19.5-13.7-.1-14.1 1.4-14.1h.1c22.5 2 34.3 23.8 34.3 23.8 11.2 19.6 26.2 25.1 39.6 25.1a63 63 0 0025.6-6c2-14.8 7.8-24.9 14.2-30.7-49.7-5.8-102-25.5-102-113.5 0-25.1 8.7-45.6 23-61.6-2.3-5.8-10-29.2 2.2-60.8a18.64 18.64 0 015-.5c8.1 0 26.4 3.1 56.6 24.1a208.21 208.21 0 01112.2 0c30.2-21 48.5-24.1 56.6-24.1a18.64 18.64 0 015 .5c12.2 31.6 4.5 55 2.2 60.8 14.3 16.1 23 36.6 23 61.6 0 88.2-52.4 107.6-102.3 113.3 8 7.1 15.2 21.1 15.2 42.5 0 30.7-.3 55.5-.3 63 0 5.4 3.1 11.5 11.4 11.5a19.35 19.35 0 004-.4C415.9 449.2 480 363.1 480 261.7 480 134.9 379.7 32 256 32z"
/>
</svg>
</span>
</a>
</div>
</div>
</div>
<slot></slot>
<footer
class="footer"
style="
background-color: transparent;
position: fixed;
bottom: 0;
width: 100%;
padding: 3rem 1.5rem;
pointer-events: none;
"
> >
<div class="content"> <NavBar withSearch="{scrollTop > 150}" />
<div class="level"> </header>
<div class="level-left"></div>
<div class="level-right"> <main class="bg-slate-50 dark:bg-[#1d232e] min-h-screen pt-10 min-w-[360px]">
{#if scrollTop > 100} <Modal
<button unstyled="{true}"
class="button level-item is-hidden-mobile" classBg="fixed top-0 left-0 z-40 w-screen h-screen flex flex-col bg-black/[.7] overflow-y-scroll"
on:click="{scrollToTop}" closeButton="{false}"
style="pointer-events: auto" ><slot
> /></Modal>
<span class="icon is-small"> </main>
<ion-icon name="arrow-up-outline"></ion-icon>
</span>
</button>
{/if}
</div>
</div>
</div>
</footer>

View file

@ -1,122 +1,81 @@
<script> <script>
import { onMount } from 'svelte' import InfiniteLoading from 'svelte-infinite-loading'
import { fetchChannels, hasQuery, countries, filteredChannels, query, search } from '../store.js'
import { onMount, onDestroy } from 'svelte'
import CountryItem from '../components/CountryItem.svelte' import CountryItem from '../components/CountryItem.svelte'
import SearchField from '../components/SearchField.svelte'
import _ from 'lodash' import _ from 'lodash'
let query = '' let _countries = []
let normQuery = '' const initLimit = 10
let regQuery = '' let limit = initLimit
let infiniteId = +new Date()
let isLoading = true let isLoading = true
let countries = []
let channels = []
let filtered = []
$: grouped = _.groupBy(filtered, 'country') const unsubscribe = filteredChannels.subscribe(reset)
onDestroy(unsubscribe)
function search() { $: visible = _countries.slice(0, limit)
normQuery = query.replace(/\s/g, '').toLowerCase()
regQuery = new RegExp(query)
if (!normQuery) { $: grouped = _.groupBy($filteredChannels, 'country.code')
filtered = channels
function reset() {
limit = initLimit
infiniteId = +new Date()
} }
filtered = channels.filter(c => { function loadMore({ detail: { loaded, complete } }) {
const normResult = c.key.includes(normQuery) if (limit < _countries.length) {
const regResult = regQuery ? regQuery.test(c.name) || regQuery.test(c.id) : false limit++
loaded()
return normResult || regResult } else {
}) complete()
}
} }
onMount(async () => { onMount(async () => {
let guides = await fetch('https://iptv-org.github.io/api/guides.json') const params = new URLSearchParams(window.location.search)
.then(response => response.json()) const q = params.get('q')
.catch(console.log) if (q) {
guides = guides.length ? guides : [] query.set(q)
guides = _.groupBy(guides, 'channel') hasQuery.set(true)
}
channels = await fetch('https://iptv-org.github.io/api/channels.json')
.then(response => response.json())
.then(arr =>
arr.map(c => {
c.key = `${c.id}_${c.name}`.replace(/\s/g, '').toLowerCase()
c.guides = guides[c.id] || []
return c
})
)
.catch(err => {
console.log(err)
return []
})
const countriesJson = await fetch('https://iptv-org.github.io/api/countries.json')
.then(response => response.json())
.catch(console.log)
countries = countriesJson.map(i => {
i.expanded = false
return i
})
filtered = channels
await fetchChannels()
_countries = Object.values($countries)
isLoading = false isLoading = false
if ($hasQuery) {
search($query)
}
}) })
</script> </script>
<div class="section"> <svelte:head>
<div class="container"> <title>iptv-org</title>
<div class="columns is-centered"> <meta name="description" content="Collection of resources dedicated to IPTV" />
<div class="column is-9"> </svelte:head>
<form class="mb-5" on:submit|preventDefault="{search}">
<div class="field-body">
<div class="field is-expanded">
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input"
type="search"
bind:value="{query}"
placeholder="Search by channel name..."
/>
</div>
<div class="control">
<button class="button is-info" type="submit">
<span class="icon is-small is-right">
<svg
xmlns="http://www.w3.org/2000/svg"
style="width: 1.25rem; height: 1.25rem"
viewBox="0 0 512 512"
>
<path
fill="#ffffff"
d="M456.69 421.39L362.6 327.3a173.81 173.81 0 0034.84-104.58C397.44 126.38 319.06 48 222.72 48S48 126.38 48 222.72s78.38 174.72 174.72 174.72A173.81 173.81 0 00327.3 362.6l94.09 94.09a25 25 0 0035.3-35.3zM97.92 222.72a124.8 124.8 0 11124.8 124.8 124.95 124.95 0 01-124.8-124.8z"
/>
</svg>
</span>
</button>
</div>
</div>
{#if !isLoading}
<p class="help">Found { filtered.length.toLocaleString() } channels</p>
{/if}
</div>
</div>
</form>
<section class="container max-w-5xl mx-auto px-2 py-20">
<SearchField bind:isLoading="{isLoading}" bind:found="{$filteredChannels.length}"></SearchField>
{#if isLoading} {#if isLoading}
<div class="level"> <div
<div class="level-item">Loading...</div> class="flex items-center justify-center w-full pt-1 pb-6 tracking-tight text-sm text-gray-500 dark:text-gray-400 font-mono"
>
loading...
</div> </div>
{/if} {#each countries as country} {/if} {#each visible as country} {#if grouped[country.code] && grouped[country.code].length > 0}
<CountryItem <CountryItem
bind:country="{country}" bind:country="{country}"
bind:channels="{grouped[country.code]}" bind:channels="{grouped[country.code]}"
bind:normQuery="{normQuery}" bind:hasQuery="{$hasQuery}"
></CountryItem> ></CountryItem>
{/each} {/if} {/each} {#if !isLoading}
</div> <InfiniteLoading on:infinite="{loadMore}" identifier="{infiniteId}" distance="{500}">
</div> <div slot="noResults"></div>
</div> <div slot="noMore"></div>
</div> <div slot="error"></div>
<div slot="spinner"></div>
</InfiniteLoading>
{/if}
</section>

191
src/store.js Normal file
View file

@ -0,0 +1,191 @@
import { writable, get } from 'svelte/store'
import { transliterate } from 'transliteration'
import _ from 'lodash'
export const query = writable('')
export const hasQuery = writable(false)
export const channels = writable([])
export const countries = writable({})
export const filteredChannels = writable([])
export function search(_query) {
setSearchParam('q', _query)
const parts = _query.toLowerCase().match(/(".*?"|[^"\s]+)+(?=\s*|\s*$)/g) || []
const filters = []
for (let value of parts) {
let field = '_key'
if (value.includes(':')) {
;[field, value] = value.split(':')
value = value.replace(/\"/g, '')
}
if (field && value) {
filters.push({ field, value })
}
}
if (!filters.length) {
hasQuery.set(false)
filteredChannels.set(get(channels))
return
}
const filtered = get(channels).filter(c => {
for (let f of filters) {
if (!c._searchable[f.field] || c._searchable[f.field].indexOf(f.value) === -1) {
return false
}
}
return true
})
filteredChannels.set(filtered)
hasQuery.set(true)
console.log('.')
}
export async function fetchChannels() {
let _countries = await fetch('https://iptv-org.github.io/api/countries.json')
.then(r => r.json())
.then(data => (data.length ? data : []))
.then(data =>
data.map(i => {
i.expanded = false
return i
})
)
.then(data => _.keyBy(data, 'code'))
.catch(console.error)
countries.set(_countries)
let _regions = await fetch('https://iptv-org.github.io/api/regions.json')
.then(r => r.json())
.then(data => (data.length ? data : []))
.then(data => _.keyBy(data, 'code'))
.catch(console.error)
let _subdivisions = await fetch('https://iptv-org.github.io/api/subdivisions.json')
.then(r => r.json())
.then(data => (data.length ? data : []))
.then(data => _.keyBy(data, 'code'))
.catch(console.error)
let _languages = await fetch('https://iptv-org.github.io/api/languages.json')
.then(r => r.json())
.then(data => (data.length ? data : []))
.then(data => _.keyBy(data, 'code'))
.catch(console.error)
let _categories = await fetch('https://iptv-org.github.io/api/categories.json')
.then(r => r.json())
.then(data => (data.length ? data : []))
.then(data => _.keyBy(data, 'id'))
.catch(console.error)
let _streams = await fetch('https://iptv-org.github.io/api/streams.json')
.then(r => r.json())
.then(data => (data.length ? data : []))
.then(data => _.groupBy(data, 'channel'))
.catch(console.error)
let _guides = await fetch('https://iptv-org.github.io/api/guides.json')
.then(r => r.json())
.then(data => (data.length ? data : []))
.then(data => _.groupBy(data, 'channel'))
.catch(console.error)
let _channels = await fetch('https://iptv-org.github.io/api/channels.json')
.then(r => r.json())
.then(arr =>
arr.map(c => {
c._raw = JSON.parse(JSON.stringify(c))
c._streams = _streams[c.id] || []
c._guides = _guides[c.id] || []
for (let field in c) {
switch (field) {
case 'languages':
c.languages = c.languages.map(code => _languages[code]).filter(i => i)
break
case 'broadcast_area':
c.broadcast_area = c.broadcast_area
.map(value => {
const [type, code] = value.split('/')
switch (type) {
case 'c':
return _countries[code]
case 'r':
return _regions[code]
case 's':
return _subdivisions[code]
}
})
.filter(i => i)
break
case 'categories':
c.categories = c.categories.map(id => _categories[id]).filter(i => i)
break
case 'country':
c.country = _countries[c.country]
break
case 'subdivision':
c.subdivision = _subdivisions[c.subdivision]
break
}
}
c._searchable = generateSearchable(c)
return c
})
)
.catch(err => {
console.error(err)
return []
})
channels.set(_channels)
filteredChannels.set(_channels)
}
function generateSearchKey(c) {
const translit = c.native_name ? transliterate(c.native_name) : null
return [c.id, c.name, c.native_name, translit]
.map(v => v || '')
.filter(v => v)
.join('_')
.replace(/\s/g, '')
.toLowerCase()
}
function generateSearchable(c) {
const searchable = {}
for (let key in c) {
if (key.startsWith('_') || c[key] === null || c[key] === undefined) continue
if (Array.isArray(c[key])) {
searchable[key] = c[key]
.map(v => (v.name ? v.name.toLowerCase() : null))
.filter(v => v)
.join(',')
} else if (typeof c[key] === 'object' && c[key].name) {
searchable[key] = c[key].name.toLowerCase()
} else {
searchable[key] = c[key].toString().toLowerCase()
}
}
searchable._key = generateSearchKey(c)
return searchable
}
function setSearchParam(key, value) {
if (window.history.pushState) {
let query = key && value ? `?${key}=${value}` : ''
query = query.replace(/\+/g, '%2B')
const url = `${window.location.protocol}//${window.location.host}${window.location.pathname}${query}`
window.history.pushState({ path: url }, '', url)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -5,8 +5,12 @@ const config = {
kit: { kit: {
adapter: adapter({ adapter: adapter({
pages: 'docs', pages: 'docs',
assets: 'docs' assets: 'docs',
}) precompress: true
}),
prerender: {
default: true
}
} }
} }

8
tailwind.config.cjs Normal file
View file

@ -0,0 +1,8 @@
module.exports = {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'class',
theme: {
extend: {}
},
plugins: []
}