initial nuxtjs ui skeleton

This commit is contained in:
ArabCoders 2024-12-13 18:31:07 +03:00
parent 3ecb3c8690
commit 62d5d29bfc
34 changed files with 43625 additions and 3 deletions

View file

@ -1,4 +1,3 @@
.git
.venv
ui/.angular
ui/node_modules
ui/

3
.gitignore vendored
View file

@ -2,7 +2,8 @@
/frontend/dist
# dependencies
/frontend/node_modules
**/node_modules/*
**/.nuxt/*
# IDEs and editors
/ui/.idea

17
.vscode/launch.json vendored
View file

@ -73,6 +73,23 @@
}
],
"justMyCode": true
},
{
"name": "Node: NuxtJS",
"request": "launch",
"runtimeArgs": [
"run",
"dev",
"--",
"--port",
"8082"
],
"runtimeExecutable": "npm",
"type": "node",
"cwd": "${workspaceFolder}/ui",
"env": {
"VUE_APP_BASE_URL": "http://localhost:8081",
}
}
]
}

23
ui/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
exported
# Node dependencies
node_modules
# Logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

10607
ui/assets/css/all.css Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,966 @@
.switch[type=checkbox] {
outline: 0;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
display: inline-block;
position: absolute;
opacity: 0
}
.switch[type=checkbox]:focus + label::after, .switch[type=checkbox]:focus + label::before, .switch[type=checkbox]:focus + label:after, .switch[type=checkbox]:focus + label:before {
outline: 1px dotted #b5b5b5
}
.switch[type=checkbox][disabled] {
cursor: not-allowed
}
.switch[type=checkbox][disabled] + label {
opacity: .5
}
.switch[type=checkbox][disabled] + label::before, .switch[type=checkbox][disabled] + label:before {
opacity: .5
}
.switch[type=checkbox][disabled] + label::after, .switch[type=checkbox][disabled] + label:after {
opacity: .5
}
.switch[type=checkbox][disabled] + label:hover {
cursor: not-allowed
}
.switch[type=checkbox] + label {
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
font-size: 1rem;
height: 2.5em;
line-height: 1.5;
padding-left: 3.5rem;
padding-top: .2rem;
cursor: pointer
}
.switch[type=checkbox] + label::before, .switch[type=checkbox] + label:before {
position: absolute;
display: block;
top: calc(50% - 1.5rem * .5);
left: 0;
width: 3rem;
height: 1.5rem;
border: .1rem solid transparent;
border-radius: 4px;
background: #b5b5b5;
content: ""
}
.switch[type=checkbox] + label::after, .switch[type=checkbox] + label:after {
display: block;
position: absolute;
top: calc(50% - 1rem * .5);
left: .25rem;
width: 1rem;
height: 1rem;
transform: translate3d(0, 0, 0);
border-radius: 4px;
background: #fff;
transition: all .25s ease-out;
content: ""
}
.switch[type=checkbox] + label .switch-active, .switch[type=checkbox] + label .switch-inactive {
font-size: .9rem;
z-index: 1;
margin-top: -4px
}
.switch[type=checkbox] + label.has-text-inside .switch-inactive {
margin-left: -1.925rem
}
.switch[type=checkbox] + label.has-text-inside .switch-active {
margin-left: -3.25rem
}
.switch[type=checkbox].is-rtl + label {
padding-left: 0;
padding-right: 3.5rem
}
.switch[type=checkbox].is-rtl + label::before, .switch[type=checkbox].is-rtl + label:before {
left: auto;
right: 0
}
.switch[type=checkbox].is-rtl + label::after, .switch[type=checkbox].is-rtl + label:after {
left: auto;
right: 1.625rem
}
.switch[type=checkbox]:checked + label::before, .switch[type=checkbox]:checked + label:before {
background: #00d1b2
}
.switch[type=checkbox]:checked + label::after {
left: 1.625rem
}
.switch[type=checkbox]:checked.is-rtl + label::after, .switch[type=checkbox]:checked.is-rtl + label:after {
left: auto;
right: .25rem
}
.switch[type=checkbox].is-outlined + label::before, .switch[type=checkbox].is-outlined + label:before {
background-color: transparent;
border-color: #b5b5b5
}
.switch[type=checkbox].is-outlined + label::after, .switch[type=checkbox].is-outlined + label:after {
background: #b5b5b5
}
.switch[type=checkbox].is-outlined:checked + label::before, .switch[type=checkbox].is-outlined:checked + label:before {
background-color: transparent;
border-color: #00d1b2
}
.switch[type=checkbox].is-outlined:checked + label::after, .switch[type=checkbox].is-outlined:checked + label:after {
background: #00d1b2
}
.switch[type=checkbox].is-thin + label::before, .switch[type=checkbox].is-thin + label:before {
top: .5454545456rem;
height: .375rem
}
.switch[type=checkbox].is-thin + label::after, .switch[type=checkbox].is-thin + label:after {
box-shadow: 0 0 3px #7a7a7a
}
.switch[type=checkbox].is-rounded + label::before, .switch[type=checkbox].is-rounded + label:before {
border-radius: 24px
}
.switch[type=checkbox].is-rounded + label::after, .switch[type=checkbox].is-rounded + label:after {
border-radius: 50%
}
.switch[type=checkbox].is-small + label {
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
font-size: .75rem;
height: 2.5em;
line-height: 1.5;
padding-left: 2.75rem;
padding-top: .2rem;
cursor: pointer
}
.switch[type=checkbox].is-small + label::before, .switch[type=checkbox].is-small + label:before {
position: absolute;
display: block;
top: calc(50% - 1.125rem * .5);
left: 0;
width: 2.25rem;
height: 1.125rem;
border: .1rem solid transparent;
border-radius: 4px;
background: #b5b5b5;
content: ""
}
.switch[type=checkbox].is-small + label::after, .switch[type=checkbox].is-small + label:after {
display: block;
position: absolute;
top: calc(50% - .625rem * .5);
left: .25rem;
width: .625rem;
height: .625rem;
transform: translate3d(0, 0, 0);
border-radius: 4px;
background: #fff;
transition: all .25s ease-out;
content: ""
}
.switch[type=checkbox].is-small + label .switch-active, .switch[type=checkbox].is-small + label .switch-inactive {
font-size: .65rem;
z-index: 1;
margin-top: -4px
}
.switch[type=checkbox].is-small + label.has-text-inside .switch-inactive {
margin-left: -1.55rem
}
.switch[type=checkbox].is-small + label.has-text-inside .switch-active {
margin-left: -2.5rem
}
.switch[type=checkbox].is-small.is-rtl + label {
padding-left: 0;
padding-right: 2.75rem
}
.switch[type=checkbox].is-small.is-rtl + label::before, .switch[type=checkbox].is-small.is-rtl + label:before {
left: auto;
right: 0
}
.switch[type=checkbox].is-small.is-rtl + label::after, .switch[type=checkbox].is-small.is-rtl + label:after {
left: auto;
right: 1.25rem
}
.switch[type=checkbox].is-small:checked + label::before, .switch[type=checkbox].is-small:checked + label:before {
background: #00d1b2
}
.switch[type=checkbox].is-small:checked + label::after {
left: 1.25rem
}
.switch[type=checkbox].is-small:checked.is-rtl + label::after, .switch[type=checkbox].is-small:checked.is-rtl + label:after {
left: auto;
right: .25rem
}
.switch[type=checkbox].is-small.is-outlined + label::before, .switch[type=checkbox].is-small.is-outlined + label:before {
background-color: transparent;
border-color: #b5b5b5
}
.switch[type=checkbox].is-small.is-outlined + label::after, .switch[type=checkbox].is-small.is-outlined + label:after {
background: #b5b5b5
}
.switch[type=checkbox].is-small.is-outlined:checked + label::before, .switch[type=checkbox].is-small.is-outlined:checked + label:before {
background-color: transparent;
border-color: #00d1b2
}
.switch[type=checkbox].is-small.is-outlined:checked + label::after, .switch[type=checkbox].is-small.is-outlined:checked + label:after {
background: #00d1b2
}
.switch[type=checkbox].is-small.is-thin + label::before, .switch[type=checkbox].is-small.is-thin + label:before {
top: .4090909093rem;
height: .28125rem
}
.switch[type=checkbox].is-small.is-thin + label::after, .switch[type=checkbox].is-small.is-thin + label:after {
box-shadow: 0 0 3px #7a7a7a
}
.switch[type=checkbox].is-small.is-rounded + label::before, .switch[type=checkbox].is-small.is-rounded + label:before {
border-radius: 24px
}
.switch[type=checkbox].is-small.is-rounded + label::after, .switch[type=checkbox].is-small.is-rounded + label:after {
border-radius: 50%
}
.switch[type=checkbox].is-medium + label {
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
font-size: 1.25rem;
height: 2.5em;
line-height: 1.5;
padding-left: 4.25rem;
padding-top: .2rem;
cursor: pointer
}
.switch[type=checkbox].is-medium + label::before, .switch[type=checkbox].is-medium + label:before {
position: absolute;
display: block;
top: calc(50% - 1.875rem * .5);
left: 0;
width: 3.75rem;
height: 1.875rem;
border: .1rem solid transparent;
border-radius: 4px;
background: #b5b5b5;
content: ""
}
.switch[type=checkbox].is-medium + label::after, .switch[type=checkbox].is-medium + label:after {
display: block;
position: absolute;
top: calc(50% - 1.375rem * .5);
left: .25rem;
width: 1.375rem;
height: 1.375rem;
transform: translate3d(0, 0, 0);
border-radius: 4px;
background: #fff;
transition: all .25s ease-out;
content: ""
}
.switch[type=checkbox].is-medium + label .switch-active, .switch[type=checkbox].is-medium + label .switch-inactive {
font-size: 1.15rem;
z-index: 1;
margin-top: -4px
}
.switch[type=checkbox].is-medium + label.has-text-inside .switch-inactive {
margin-left: -2.3rem
}
.switch[type=checkbox].is-medium + label.has-text-inside .switch-active {
margin-left: -4rem
}
.switch[type=checkbox].is-medium.is-rtl + label {
padding-left: 0;
padding-right: 4.25rem
}
.switch[type=checkbox].is-medium.is-rtl + label::before, .switch[type=checkbox].is-medium.is-rtl + label:before {
left: auto;
right: 0
}
.switch[type=checkbox].is-medium.is-rtl + label::after, .switch[type=checkbox].is-medium.is-rtl + label:after {
left: auto;
right: 2rem
}
.switch[type=checkbox].is-medium:checked + label::before, .switch[type=checkbox].is-medium:checked + label:before {
background: #00d1b2
}
.switch[type=checkbox].is-medium:checked + label::after {
left: 2rem
}
.switch[type=checkbox].is-medium:checked.is-rtl + label::after, .switch[type=checkbox].is-medium:checked.is-rtl + label:after {
left: auto;
right: .25rem
}
.switch[type=checkbox].is-medium.is-outlined + label::before, .switch[type=checkbox].is-medium.is-outlined + label:before {
background-color: transparent;
border-color: #b5b5b5
}
.switch[type=checkbox].is-medium.is-outlined + label::after, .switch[type=checkbox].is-medium.is-outlined + label:after {
background: #b5b5b5
}
.switch[type=checkbox].is-medium.is-outlined:checked + label::before, .switch[type=checkbox].is-medium.is-outlined:checked + label:before {
background-color: transparent;
border-color: #00d1b2
}
.switch[type=checkbox].is-medium.is-outlined:checked + label::after, .switch[type=checkbox].is-medium.is-outlined:checked + label:after {
background: #00d1b2
}
.switch[type=checkbox].is-medium.is-thin + label::before, .switch[type=checkbox].is-medium.is-thin + label:before {
top: .6818181819rem;
height: .46875rem
}
.switch[type=checkbox].is-medium.is-thin + label::after, .switch[type=checkbox].is-medium.is-thin + label:after {
box-shadow: 0 0 3px #7a7a7a
}
.switch[type=checkbox].is-medium.is-rounded + label::before, .switch[type=checkbox].is-medium.is-rounded + label:before {
border-radius: 24px
}
.switch[type=checkbox].is-medium.is-rounded + label::after, .switch[type=checkbox].is-medium.is-rounded + label:after {
border-radius: 50%
}
.switch[type=checkbox].is-large + label {
position: relative;
display: inline-flex;
align-items: center;
justify-content: flex-start;
font-size: 1.5rem;
height: 2.5em;
line-height: 1.5;
padding-left: 5rem;
padding-top: .2rem;
cursor: pointer
}
.switch[type=checkbox].is-large + label::before, .switch[type=checkbox].is-large + label:before {
position: absolute;
display: block;
top: calc(50% - 2.25rem * .5);
left: 0;
width: 4.5rem;
height: 2.25rem;
border: .1rem solid transparent;
border-radius: 4px;
background: #b5b5b5;
content: ""
}
.switch[type=checkbox].is-large + label::after, .switch[type=checkbox].is-large + label:after {
display: block;
position: absolute;
top: calc(50% - 1.75rem * .5);
left: .25rem;
width: 1.75rem;
height: 1.75rem;
transform: translate3d(0, 0, 0);
border-radius: 4px;
background: #fff;
transition: all .25s ease-out;
content: ""
}
.switch[type=checkbox].is-large + label .switch-active, .switch[type=checkbox].is-large + label .switch-inactive {
font-size: 1.4rem;
z-index: 1;
margin-top: -4px
}
.switch[type=checkbox].is-large + label.has-text-inside .switch-inactive {
margin-left: -2.675rem
}
.switch[type=checkbox].is-large + label.has-text-inside .switch-active {
margin-left: -4.75rem
}
.switch[type=checkbox].is-large.is-rtl + label {
padding-left: 0;
padding-right: 5rem
}
.switch[type=checkbox].is-large.is-rtl + label::before, .switch[type=checkbox].is-large.is-rtl + label:before {
left: auto;
right: 0
}
.switch[type=checkbox].is-large.is-rtl + label::after, .switch[type=checkbox].is-large.is-rtl + label:after {
left: auto;
right: 2.375rem
}
.switch[type=checkbox].is-large:checked + label::before, .switch[type=checkbox].is-large:checked + label:before {
background: #00d1b2
}
.switch[type=checkbox].is-large:checked + label::after {
left: 2.375rem
}
.switch[type=checkbox].is-large:checked.is-rtl + label::after, .switch[type=checkbox].is-large:checked.is-rtl + label:after {
left: auto;
right: .25rem
}
.switch[type=checkbox].is-large.is-outlined + label::before, .switch[type=checkbox].is-large.is-outlined + label:before {
background-color: transparent;
border-color: #b5b5b5
}
.switch[type=checkbox].is-large.is-outlined + label::after, .switch[type=checkbox].is-large.is-outlined + label:after {
background: #b5b5b5
}
.switch[type=checkbox].is-large.is-outlined:checked + label::before, .switch[type=checkbox].is-large.is-outlined:checked + label:before {
background-color: transparent;
border-color: #00d1b2
}
.switch[type=checkbox].is-large.is-outlined:checked + label::after, .switch[type=checkbox].is-large.is-outlined:checked + label:after {
background: #00d1b2
}
.switch[type=checkbox].is-large.is-thin + label::before, .switch[type=checkbox].is-large.is-thin + label:before {
top: .8181818183rem;
height: .5625rem
}
.switch[type=checkbox].is-large.is-thin + label::after, .switch[type=checkbox].is-large.is-thin + label:after {
box-shadow: 0 0 3px #7a7a7a
}
.switch[type=checkbox].is-large.is-rounded + label::before, .switch[type=checkbox].is-large.is-rounded + label:before {
border-radius: 24px
}
.switch[type=checkbox].is-large.is-rounded + label::after, .switch[type=checkbox].is-large.is-rounded + label:after {
border-radius: 50%
}
.switch[type=checkbox].is-white + label .switch-active {
display: none
}
.switch[type=checkbox].is-white + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-white:checked + label::before, .switch[type=checkbox].is-white:checked + label:before {
background: #fff
}
.switch[type=checkbox].is-white:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-white:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-white.is-outlined:checked + label::before, .switch[type=checkbox].is-white.is-outlined:checked + label:before {
background-color: transparent;
border-color: #fff !important
}
.switch[type=checkbox].is-white.is-outlined:checked + label::after, .switch[type=checkbox].is-white.is-outlined:checked + label:after {
background: #fff
}
.switch[type=checkbox].is-white.is-thin.is-outlined + label::after, .switch[type=checkbox].is-white.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-white + label::before, .switch[type=checkbox].is-unchecked-white + label:before {
background: #fff
}
.switch[type=checkbox].is-unchecked-white.is-outlined + label::before, .switch[type=checkbox].is-unchecked-white.is-outlined + label:before {
background-color: transparent;
border-color: #fff !important
}
.switch[type=checkbox].is-unchecked-white.is-outlined + label::after, .switch[type=checkbox].is-unchecked-white.is-outlined + label:after {
background: #fff
}
.switch[type=checkbox].is-black + label .switch-active {
display: none
}
.switch[type=checkbox].is-black + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-black:checked + label::before, .switch[type=checkbox].is-black:checked + label:before {
background: #0a0a0a
}
.switch[type=checkbox].is-black:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-black:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-black.is-outlined:checked + label::before, .switch[type=checkbox].is-black.is-outlined:checked + label:before {
background-color: transparent;
border-color: #0a0a0a !important
}
.switch[type=checkbox].is-black.is-outlined:checked + label::after, .switch[type=checkbox].is-black.is-outlined:checked + label:after {
background: #0a0a0a
}
.switch[type=checkbox].is-black.is-thin.is-outlined + label::after, .switch[type=checkbox].is-black.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-black + label::before, .switch[type=checkbox].is-unchecked-black + label:before {
background: #0a0a0a
}
.switch[type=checkbox].is-unchecked-black.is-outlined + label::before, .switch[type=checkbox].is-unchecked-black.is-outlined + label:before {
background-color: transparent;
border-color: #0a0a0a !important
}
.switch[type=checkbox].is-unchecked-black.is-outlined + label::after, .switch[type=checkbox].is-unchecked-black.is-outlined + label:after {
background: #0a0a0a
}
.switch[type=checkbox].is-light + label .switch-active {
display: none
}
.switch[type=checkbox].is-light + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-light:checked + label::before, .switch[type=checkbox].is-light:checked + label:before {
background: #f5f5f5
}
.switch[type=checkbox].is-light:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-light:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-light.is-outlined:checked + label::before, .switch[type=checkbox].is-light.is-outlined:checked + label:before {
background-color: transparent;
border-color: #f5f5f5 !important
}
.switch[type=checkbox].is-light.is-outlined:checked + label::after, .switch[type=checkbox].is-light.is-outlined:checked + label:after {
background: #f5f5f5
}
.switch[type=checkbox].is-light.is-thin.is-outlined + label::after, .switch[type=checkbox].is-light.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-light + label::before, .switch[type=checkbox].is-unchecked-light + label:before {
background: #f5f5f5
}
.switch[type=checkbox].is-unchecked-light.is-outlined + label::before, .switch[type=checkbox].is-unchecked-light.is-outlined + label:before {
background-color: transparent;
border-color: #f5f5f5 !important
}
.switch[type=checkbox].is-unchecked-light.is-outlined + label::after, .switch[type=checkbox].is-unchecked-light.is-outlined + label:after {
background: #f5f5f5
}
.switch[type=checkbox].is-dark + label .switch-active {
display: none
}
.switch[type=checkbox].is-dark + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-dark:checked + label::before, .switch[type=checkbox].is-dark:checked + label:before {
background: #363636
}
.switch[type=checkbox].is-dark:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-dark:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-dark.is-outlined:checked + label::before, .switch[type=checkbox].is-dark.is-outlined:checked + label:before {
background-color: transparent;
border-color: #363636 !important
}
.switch[type=checkbox].is-dark.is-outlined:checked + label::after, .switch[type=checkbox].is-dark.is-outlined:checked + label:after {
background: #363636
}
.switch[type=checkbox].is-dark.is-thin.is-outlined + label::after, .switch[type=checkbox].is-dark.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-dark + label::before, .switch[type=checkbox].is-unchecked-dark + label:before {
background: #363636
}
.switch[type=checkbox].is-unchecked-dark.is-outlined + label::before, .switch[type=checkbox].is-unchecked-dark.is-outlined + label:before {
background-color: transparent;
border-color: #363636 !important
}
.switch[type=checkbox].is-unchecked-dark.is-outlined + label::after, .switch[type=checkbox].is-unchecked-dark.is-outlined + label:after {
background: #363636
}
.switch[type=checkbox].is-primary + label .switch-active {
display: none
}
.switch[type=checkbox].is-primary + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-primary:checked + label::before, .switch[type=checkbox].is-primary:checked + label:before {
background: #00d1b2
}
.switch[type=checkbox].is-primary:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-primary:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-primary.is-outlined:checked + label::before, .switch[type=checkbox].is-primary.is-outlined:checked + label:before {
background-color: transparent;
border-color: #00d1b2 !important
}
.switch[type=checkbox].is-primary.is-outlined:checked + label::after, .switch[type=checkbox].is-primary.is-outlined:checked + label:after {
background: #00d1b2
}
.switch[type=checkbox].is-primary.is-thin.is-outlined + label::after, .switch[type=checkbox].is-primary.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-primary + label::before, .switch[type=checkbox].is-unchecked-primary + label:before {
background: #00d1b2
}
.switch[type=checkbox].is-unchecked-primary.is-outlined + label::before, .switch[type=checkbox].is-unchecked-primary.is-outlined + label:before {
background-color: transparent;
border-color: #00d1b2 !important
}
.switch[type=checkbox].is-unchecked-primary.is-outlined + label::after, .switch[type=checkbox].is-unchecked-primary.is-outlined + label:after {
background: #00d1b2
}
.switch[type=checkbox].is-link + label .switch-active {
display: none
}
.switch[type=checkbox].is-link + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-link:checked + label::before, .switch[type=checkbox].is-link:checked + label:before {
background: #485fc7
}
.switch[type=checkbox].is-link:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-link:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-link.is-outlined:checked + label::before, .switch[type=checkbox].is-link.is-outlined:checked + label:before {
background-color: transparent;
border-color: #485fc7 !important
}
.switch[type=checkbox].is-link.is-outlined:checked + label::after, .switch[type=checkbox].is-link.is-outlined:checked + label:after {
background: #485fc7
}
.switch[type=checkbox].is-link.is-thin.is-outlined + label::after, .switch[type=checkbox].is-link.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-link + label::before, .switch[type=checkbox].is-unchecked-link + label:before {
background: #485fc7
}
.switch[type=checkbox].is-unchecked-link.is-outlined + label::before, .switch[type=checkbox].is-unchecked-link.is-outlined + label:before {
background-color: transparent;
border-color: #485fc7 !important
}
.switch[type=checkbox].is-unchecked-link.is-outlined + label::after, .switch[type=checkbox].is-unchecked-link.is-outlined + label:after {
background: #485fc7
}
.switch[type=checkbox].is-info + label .switch-active {
display: none
}
.switch[type=checkbox].is-info + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-info:checked + label::before, .switch[type=checkbox].is-info:checked + label:before {
background: #3e8ed0
}
.switch[type=checkbox].is-info:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-info:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-info.is-outlined:checked + label::before, .switch[type=checkbox].is-info.is-outlined:checked + label:before {
background-color: transparent;
border-color: #3e8ed0 !important
}
.switch[type=checkbox].is-info.is-outlined:checked + label::after, .switch[type=checkbox].is-info.is-outlined:checked + label:after {
background: #3e8ed0
}
.switch[type=checkbox].is-info.is-thin.is-outlined + label::after, .switch[type=checkbox].is-info.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-info + label::before, .switch[type=checkbox].is-unchecked-info + label:before {
background: #3e8ed0
}
.switch[type=checkbox].is-unchecked-info.is-outlined + label::before, .switch[type=checkbox].is-unchecked-info.is-outlined + label:before {
background-color: transparent;
border-color: #3e8ed0 !important
}
.switch[type=checkbox].is-unchecked-info.is-outlined + label::after, .switch[type=checkbox].is-unchecked-info.is-outlined + label:after {
background: #3e8ed0
}
.switch[type=checkbox].is-success + label .switch-active {
display: none
}
.switch[type=checkbox].is-success + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-success:checked + label::before, .switch[type=checkbox].is-success:checked + label:before {
background: #48c78e
}
.switch[type=checkbox].is-success:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-success:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-success.is-outlined:checked + label::before, .switch[type=checkbox].is-success.is-outlined:checked + label:before {
background-color: transparent;
border-color: #48c78e !important
}
.switch[type=checkbox].is-success.is-outlined:checked + label::after, .switch[type=checkbox].is-success.is-outlined:checked + label:after {
background: #48c78e
}
.switch[type=checkbox].is-success.is-thin.is-outlined + label::after, .switch[type=checkbox].is-success.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-success + label::before, .switch[type=checkbox].is-unchecked-success + label:before {
background: #48c78e
}
.switch[type=checkbox].is-unchecked-success.is-outlined + label::before, .switch[type=checkbox].is-unchecked-success.is-outlined + label:before {
background-color: transparent;
border-color: #48c78e !important
}
.switch[type=checkbox].is-unchecked-success.is-outlined + label::after, .switch[type=checkbox].is-unchecked-success.is-outlined + label:after {
background: #48c78e
}
.switch[type=checkbox].is-warning + label .switch-active {
display: none
}
.switch[type=checkbox].is-warning + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-warning:checked + label::before, .switch[type=checkbox].is-warning:checked + label:before {
background: #ffe08a
}
.switch[type=checkbox].is-warning:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-warning:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-warning.is-outlined:checked + label::before, .switch[type=checkbox].is-warning.is-outlined:checked + label:before {
background-color: transparent;
border-color: #ffe08a !important
}
.switch[type=checkbox].is-warning.is-outlined:checked + label::after, .switch[type=checkbox].is-warning.is-outlined:checked + label:after {
background: #ffe08a
}
.switch[type=checkbox].is-warning.is-thin.is-outlined + label::after, .switch[type=checkbox].is-warning.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-warning + label::before, .switch[type=checkbox].is-unchecked-warning + label:before {
background: #ffe08a
}
.switch[type=checkbox].is-unchecked-warning.is-outlined + label::before, .switch[type=checkbox].is-unchecked-warning.is-outlined + label:before {
background-color: transparent;
border-color: #ffe08a !important
}
.switch[type=checkbox].is-unchecked-warning.is-outlined + label::after, .switch[type=checkbox].is-unchecked-warning.is-outlined + label:after {
background: #ffe08a
}
.switch[type=checkbox].is-danger + label .switch-active {
display: none
}
.switch[type=checkbox].is-danger + label .switch-inactive {
display: inline-block
}
.switch[type=checkbox].is-danger:checked + label::before, .switch[type=checkbox].is-danger:checked + label:before {
background: #f14668
}
.switch[type=checkbox].is-danger:checked + label .switch-active {
display: inline-block
}
.switch[type=checkbox].is-danger:checked + label .switch-inactive {
display: none
}
.switch[type=checkbox].is-danger.is-outlined:checked + label::before, .switch[type=checkbox].is-danger.is-outlined:checked + label:before {
background-color: transparent;
border-color: #f14668 !important
}
.switch[type=checkbox].is-danger.is-outlined:checked + label::after, .switch[type=checkbox].is-danger.is-outlined:checked + label:after {
background: #f14668
}
.switch[type=checkbox].is-danger.is-thin.is-outlined + label::after, .switch[type=checkbox].is-danger.is-thin.is-outlined + label:after {
box-shadow: none
}
.switch[type=checkbox].is-unchecked-danger + label::before, .switch[type=checkbox].is-unchecked-danger + label:before {
background: #f14668
}
.switch[type=checkbox].is-unchecked-danger.is-outlined + label::before, .switch[type=checkbox].is-unchecked-danger.is-outlined + label:before {
background-color: transparent;
border-color: #f14668 !important
}
.switch[type=checkbox].is-unchecked-danger.is-outlined + label::after, .switch[type=checkbox].is-unchecked-danger.is-outlined + label:after {
background: #f14668
}
.field-body .switch[type=checkbox] + label {
margin-top: .375em
}

24828
ui/assets/css/bulma.css vendored Normal file

File diff suppressed because it is too large Load diff

190
ui/assets/css/style.css Normal file
View file

@ -0,0 +1,190 @@
* {
unicode-bidi: plaintext;
}
.container {
padding: 1em;
margin-top: 1em;
}
.container,
.card,
.box,
.navbar {
border-radius: 15px;
}
.is-bordered-danger {
border: 1px solid red;
}
.is-bordered-info {
border: 1px solid #3e8ed0;
}
html,
body {
background-color: #eaeaea;
}
.container {
background-color: #ffffff;
}
hr {
background-color: #000;
}
.is-unselectable {
user-select: none;
}
.is-text-overflow {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-color-scheme: dark) {
* {
unicode-bidi: plaintext;
}
.container {
padding: 1em;
margin-top: 1em;
}
.container,
.card,
.box,
.navbar {
border-radius: 15px;
}
.is-bordered-danger {
border: 1px solid red;
}
.is-bordered-info {
border: 1px solid #3e8ed0;
}
hr {
background-color: #fff;
}
html,
body {
background-color: #000000;
}
.container {
background-color: #0f1010;
}
.is-unselectable {
user-select: none;
}
.is-text-overflow {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.is-borderless {
border: none;
}
.is-marginless {
margin: 0 !important;
}
.is-paddingless {
padding: 0 !important;
}
.section {
padding: 3rem 3rem;
}
.section.is-medium {
padding: 9rem 4.5rem;
}
.section.is-large {
padding: 18rem 6rem;
}
.footer {
background-color: #fafafa;
padding: 3rem 1.5rem 6rem;
}
.progress-bar {
border-radius: 15px;
position: relative;
width: 100%;
height: 30px;
background-color: #F5F5F5;
}
.progress,
.progress-percentage {
border-radius: 15px;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.progress-percentage {
text-align: center;
z-index: 2;
line-height: 30px;
}
.progress {
z-index: 1;
background-color: #00d1b2;
}
@media (prefers-color-scheme: dark) {
.footer {
background-color: #121212;
}
.progress-bar {
border-radius: 15px;
position: relative;
width: 100%;
height: 30px;
background-color: #383636;
}
.progress,
.progress-percentage {
border-radius: 15px;
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
}
.progress-percentage {
text-align: center;
z-index: 2;
line-height: 30px;
}
.progress {
z-index: 1;
background-color: #087363;
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

394
ui/components/History.vue Normal file
View file

@ -0,0 +1,394 @@
<template>
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showCompleted = !showCompleted">
<span class="icon-text title is-4">
<span class="icon">
<i :class="showCompleted ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</span>
<span>History <span v-if="hasItems">({{ getTotal }})</span></span>
</span>
</h1>
<div v-if="showCompleted">
<div class="columns is-multiline is-mobile has-text-centered" v-if="hasItems">
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-ghost is-inverted"
@click="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block">
<span class="icon">
<i :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span>
<span v-if="!masterSelectAll">Select All</span>
<span v-else>Unselect All</span>
</span>
</button>
</div>
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-danger" :disabled="!hasSelected"
@click="$emit('deleteItem', 'completed', selectedElms); selectedElms = []">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-trash-can" />
</span>
<span>Remove Selected</span>
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasCompleted">
<button type="button" class="button is-fullwidth is-primary is-inverted" @click="clearCompleted">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-circle-check" />
</span>
<span>Clear Completed</span>
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasIncomplete">
<button type="button" class="button is-fullwidth is-info is-inverted" @click="clearIncomplete">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-circle-xmark" />
</span>
<span>Clear Incomplete</span>
</span>
</button>
</div>
<div class="column is-half-mobile" v-if="hasIncomplete">
<button type="button" class="button is-fullwidth is-warning is-inverted" @click="requeueIncomplete">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-rotate-right" />
</span>
<span>Re-queue Incomplete</span>
</span>
</button>
</div>
<div class="column is-1-tablet">
<button type="button" class="button is-fullwidth" @click="direction = direction === 'desc' ? 'asc' : 'desc'">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid" :class="direction === 'desc' ? 'fa-arrow-down-a-z' : 'fa-arrow-up-a-z'" />
</span>
</span>
</button>
</div>
</div>
<div class="columns is-multiline">
<LazyLoader :unrender="true" :min-height="210" class="column is-6" v-for="item in sortCompleted" :key="item._id">
<div class="card"
:class="{ 'is-bordered-danger': item.status === 'error', 'is-bordered-info': item.live_in || item.is_live }">
<header class="card-header has-tooltip">
<div class="card-header-title is-text-overflow is-block" v-tooltip="item.title">
<a v-if="item.filename" referrerpolicy="no-referrer" :href="makeDownload(config, item, 'm3u8')"
@click.prevent="$emit('playItem', item)">
{{ item.title }}
</a>
<span v-else>{{ item.title }}</span>
</div>
<div class="card-header-icon" v-if="item.filename">
<a :href="makeDownload(config, item)" :download="item.filename?.split('/').reverse()[0]"
class="has-text-primary" v-tooltip="'Download item.'">
<span class="icon"><i class="fa-solid fa-download" /></span>
</a>
</div>
</header>
<div class="card-content">
<div class="columns is-mobile is-multiline">
<div class="column is-12" v-if="item.live_in">
<span class="has-text-info">
LIVE stream is scheduled to start at {{ moment(item.live_in).format() }}
</span>
</div>
<div class="column is-12" v-if="item.error">
<span class="has-text-danger">{{ item.error }}</span>
</div>
<div class="column is-12" v-if="showMessage(item)">
<span class="has-text-danger">{{ item.msg }}</span>
</div>
<div class="column is-half-mobile has-text-centered">
<span v-if="!item.live_in && !item.is_live">
<span class="icon-text">
<span class="icon"
:class="{ 'has-text-success': item.status === 'finished', 'has-text-danger': item.status !== 'finished' }">
<i :class="setIcon(item)" />
</span>
<span v-if="item.status == 'finished' && item.is_live">Stream Ended</span>
<span v-else>{{ ucFirst(item.status) }}</span>
</span>
</span>
<span v-else>
<span class="icon-text">
<span class="icon has-text-info">
<i class="fa-solid fa-calendar" />
</span>
<span>Live Stream</span>
</span>
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<span :date-datetime="item.datetime" v-tooltip="moment(item.datetime).format('YYYY-M-DD H:mm Z')">
{{ moment(item.datetime).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered" v-if="item.live_in && item.status != 'finished'">
<span :date-datetime="item.datetime"
v-tooltip="'Will start at: ' + moment(item.live_in).format('YYYY-M-DD H:mm Z')">
{{ moment(item.live_in).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered" v-if="item.file_size">
{{ formatBytes(item.file_size) }}
</div>
<div class="column is-half-mobile has-text-centered">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
Select
</label>
</div>
</div>
<div class="columns is-mobile is-multiline">
<div class="column is-half-mobile" v-if="item.status != 'finished'">
<a class="button is-warning is-fullwidth" v-tooltip="'Re-queue incomplete download.'"
@click="reQueueItem(item)">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-rotate-right" />
</span>
<span>Re-queue</span>
</span>
</a>
</div>
<div class="column is-half-mobile">
<a class="button is-danger is-fullwidth" @click="$emit('deleteItem', 'completed', item._id)">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-trash-can" />
</span>
<span>Remove</span>
</span>
</a>
</div>
<div class="column is-half-mobile" v-if="config.app?.keep_archive && item.status != 'finished'">
<a class="button is-danger is-light is-fullwidth" v-tooltip="'Add link to archive.'"
@click="$emit('archiveItem', 'completed', item)">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-box-archive" />
</span>
<span>Archive</span>
</span>
</a>
</div>
<div class="column is-half-mobile">
<a referrerpolicy="no-referrer" class="button is-link is-fullwidth" target="_blank" :href="item.url">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-up-right-from-square" />
</span>
<span>Visit Link</span>
</span>
</a>
</div>
</div>
</div>
</div>
</LazyLoader>
</div>
<div class="content has-text-centered" v-if="!hasItems">
<p v-if="config.isConnected">
<span class="icon-text">
<span class="icon has-text-success">
<i class="fa-solid fa-circle-check" />
</span>
<span>No records.</span>
</span>
</p>
<p v-else>
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-spinner fa-spin" />
</span>
<span>Connecting...</span>
</span>
</p>
</div>
</div>
</template>
<script setup>
import { defineProps, computed, ref, watch, defineEmits } from 'vue';
import moment from "moment";
import { useStorage } from '@vueuse/core'
import LazyLoader from './LazyLoader'
import { makeDownload, formatBytes, ucFirst } from '../utils/index'
const emits = defineEmits(['deleteItem', 'addItem', 'playItem', 'archiveItem']);
const props = defineProps({
completed: {
type: Object,
required: true
},
config: {
type: Object,
required: true
},
})
const selectedElms = ref([]);
const masterSelectAll = ref(false);
const showCompleted = useStorage('showCompleted', true)
const direction = useStorage('sortCompleted', 'desc')
watch(masterSelectAll, (value) => {
for (const key in props.completed) {
const element = props.completed[key];
if (value) {
selectedElms.value.push(element._id);
} else {
selectedElms.value = [];
}
}
})
const sortCompleted = computed(() => {
const thisDirection = direction.value;
return Object.values(props.completed).sort((a, b) => {
if (thisDirection === 'asc') {
return new Date(a.datetime) - new Date(b.datetime);
}
return new Date(b.datetime) - new Date(a.datetime);
})
})
const hasSelected = computed(() => selectedElms.value.length > 0)
const hasItems = computed(() => Object.keys(props.completed)?.length > 0)
const getTotal = computed(() => Object.keys(props.completed)?.length);
const showMessage = (item) => {
if (!item?.msg || item.msg === item?.error) {
return false
}
return item.msg.length > 0;
}
const hasIncomplete = computed(() => {
if (Object.keys(props.completed)?.length < 0) {
return false;
}
for (const key in props.completed) {
const element = props.completed[key];
if (element.status !== 'finished') {
return true;
}
}
return false;
})
const hasCompleted = computed(() => {
if (Object.keys(props.completed)?.length < 0) {
return false;
}
for (const key in props.completed) {
const element = props.completed[key];
if (element.status === 'finished') {
return true;
}
}
return false;
})
const clearCompleted = () => {
const state = confirm('Are you sure you want to clear all completed downloads?');
if (false === state) {
return;
}
const keys = {};
for (const key in props.completed) {
if (props.completed[key].status === 'finished') {
keys[key] = props.completed[key]._id;
}
}
emits('deleteItem', 'completed', keys);
}
const clearIncomplete = () => {
const state = confirm('Are you sure you want to clear all incomplete downloads?');
if (false === state) {
return;
}
const keys = {};
for (const key in props.completed) {
if (props.completed[key].status !== 'finished') {
keys[key] = props.completed[key]._id;
}
}
emits('deleteItem', 'completed', keys);
}
const setIcon = (item) => {
if (item.status === 'finished' && item.is_live) {
return 'fa-solid fa-globe';
}
if (item.status === 'finished') {
return 'fa-solid fa-circle-check';
}
if (item.status === 'error') {
return 'fa-solid fa-circle-xmark';
}
if (item.status === 'canceled') {
return 'fa-solid fa-eject';
}
return 'fa-solid fa-circle';
}
const requeueIncomplete = () => {
if (false === confirm('Are you sure you want to re-queue all incomplete downloads?')) {
return false;
}
for (const key in props.completed) {
const item = props.completed[key];
if (item.status !== 'finished') {
emits('deleteItem', 'completed', key);
emits('addItem', {
url: item.url,
format: item.format,
quality: item.quality,
folder: item.folder,
ytdlp_config: item.ytdlp_config,
ytdlp_cookies: item.ytdlp_cookies,
output_template: item.output_template,
});
}
}
}
const reQueueItem = (item) => {
emits('deleteItem', 'completed', item._id);
emits('addItem', {
url: item.url,
format: item.format,
quality: item.quality,
folder: item.folder,
ytdlp_config: item.ytdlp_config,
ytdlp_cookies: item.ytdlp_cookies,
output_template: item.output_template,
});
}
</script>

View file

@ -0,0 +1,76 @@
<template>
<div ref="targetEl" :style="`min-height:${fixedMinHeight ? fixedMinHeight : (minHeight)}px`">
<slot v-if="shouldRender"/>
</div>
</template>
<script>
import {nextTick, ref} from 'vue'
import {useIntersectionObserver} from '@vueuse/core'
function onIdle(cb = () => {
}) {
if ("requestIdleCallback" in window) {
window.requestIdleCallback(cb);
} else {
setTimeout(() => nextTick(cb), 300);
}
}
export default {
props: {
renderOnIdle: Boolean,
unrender: Boolean,
minHeight: Number,
unrenderDelay: {
type: Number,
default: 6000,
},
},
setup(props) {
const shouldRender = ref(false);
const targetEl = ref();
const fixedMinHeight = ref(0);
let unrenderTimer;
let renderTimer;
const {stop} = useIntersectionObserver(targetEl, ([{isIntersecting}]) => {
if (isIntersecting) {
// perhaps the user re-scrolled to a component that was set to unrender. In that case stop the un-rendering timer
clearTimeout(unrenderTimer);
// if we're dealing under-rendering lets add a waiting period of 200ms before rendering.
// If a component enters the viewport and also leaves it within 200ms it will not render at all.
// This saves work and improves performance when user scrolls very fast
renderTimer = setTimeout(() => (shouldRender.value = true), props.unrender ? 200 : 0);
shouldRender.value = true;
if (!props.unrender) {
stop();
}
} else if (props.unrender) {
// if the component was set to render, cancel that
clearTimeout(renderTimer);
unrenderTimer = setTimeout(() => {
if (targetEl.value?.clientHeight) {
fixedMinHeight.value = targetEl.value.clientHeight;
}
shouldRender.value = false;
}, props.unrenderDelay);
}
}, {
rootMargin: "600px",
}
);
if (props.renderOnIdle) {
onIdle(() => {
shouldRender.value = true;
if (!props.unrender) {
stop();
}
});
}
return {targetEl, shouldRender, fixedMinHeight};
},
};
</script>

237
ui/components/Queue.vue Normal file
View file

@ -0,0 +1,237 @@
<template>
<h1 class="mt-3 is-size-3 is-clickable is-unselectable" @click="showQueue = !showQueue">
<span class="icon-text title is-4">
<span class="icon">
<i class="fas" :class="showQueue ? 'fa-solid fa-arrow-up' : 'fa-solid fa-arrow-down'" />
</span>
<span>Queue <span v-if="hasQueuedItems">({{ getTotal }})</span></span>
</span>
</h1>
<div v-if="showQueue">
<div class="columns is-multiline is-mobile has-text-centered" v-if="hasQueuedItems">
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-ghost" @click="masterSelectAll = !masterSelectAll">
<span class="icon-text is-block">
<span class="icon">
<i class="fas" :class="!masterSelectAll ? 'fa-regular fa-square-check' : 'fa-regular fa-square'" />
</span>
<span v-if="!masterSelectAll">Select All</span>
<span v-else>Unselect All</span>
</span>
</button>
</div>
<div class="column is-half-mobile">
<button type="button" class="button is-fullwidth is-danger" :disabled="!hasSelected"
@click="$emit('deleteItem', 'queue', selectedElms); selectedElms = []">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-trash-can" />
</span>
<span>Cancel Selected</span>
</span>
</button>
</div>
</div>
<div class="columns is-multiline">
<LazyLoader :unrender="true" :min-height="265" class="column is-6" v-for="item in queue" :key="item._id">
<div class="card">
<header class="card-header has-tooltip" v-tooltip="item.title">
<div class="card-header-title has-text-centered is-text-overflow is-block">
{{ item.title }}
</div>
</header>
<div class="card-content">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<div class="progress-bar is-round">
<div class="progress-percentage">{{ updateProgress(item) }}</div>
<div class="progress" :style="{ width: percentPipe(item.percent) + '%' }"></div>
</div>
</div>
<div class="column is-half-mobile has-text-centered">
<span class="icon-text">
<span class="icon" :class="{ 'has-text-success': item.status == 'downloading' }">
<i class="fas" :class="setIcon(item)" />
</span>
<span v-if="item.status == 'downloading' && item.is_live">Live Streaming</span>
<span v-else>{{ capitalize(item.status) }}</span>
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<span :data-datetime="item.datetime"
v-tooltip="moment(item.datetime).format('MMMM Do YYYY, h:mm:ss a')">
{{ moment(item.datetime).fromNow() }}
</span>
</div>
<div class="column is-half-mobile has-text-centered">
<label class="checkbox is-block">
<input class="completed-checkbox" type="checkbox" v-model="selectedElms" :id="'checkbox-' + item._id"
:value="item._id">
Select
</label>
</div>
</div>
<div class="columns is-multiline is-mobile">
<div class="column is-half-mobile">
<button class="button is-danger is-fullwidth" @click="confirmDelete(item);">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-trash-can" />
</span>
<span>Cancel</span>
</span>
</button>
</div>
<div class="column is-half-mobile">
<a referrerpolicy="no-referrer" class="button is-link is-fullwidth" target="_blank" :href="item.url">
<span class="icon-text is-block">
<span class="icon">
<i class="fa-solid fa-up-right-from-square" />
</span>
<span>Visit Link</span>
</span>
</a>
</div>
</div>
</div>
</div>
</LazyLoader>
</div>
<div class="content has-text-centered" v-if="!hasQueuedItems">
<p v-if="config.isConnected">
<span class="icon-text">
<span class="icon has-text-success">
<i class="fa-solid fa-circle-check" />
</span>
<span>No queued items.</span>
</span>
</p>
<p v-else>
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-spinner fa-spin" />
</span>
<span>Connecting...</span>
</span>
</p>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, watch, computed } from 'vue';
import moment from "moment";
import { useStorage } from '@vueuse/core'
import LazyLoader from './LazyLoader'
const emit = defineEmits(['deleteItem']);
const props = defineProps({
queue: {
type: Object,
required: true
},
config: {
type: Object,
required: true
},
})
const selectedElms = ref([]);
const masterSelectAll = ref(false);
const showQueue = useStorage('showQueue', true)
watch(masterSelectAll, (value) => {
for (const key in props.queue) {
const element = props.queue[key];
if (value) {
selectedElms.value.push(element._id);
} else {
selectedElms.value = [];
}
}
})
const hasSelected = computed(() => selectedElms.value.length > 0)
const hasQueuedItems = computed(() => Object.keys(props.queue)?.length > 0)
const getTotal = computed(() => Object.keys(props.queue)?.length);
const setIcon = (item) => {
if (item.status === 'downloading' && item.is_live) {
return 'fa-solid fa-globe';
}
if (item.status === 'downloading') {
return 'fa-solid fa-circle-check';
}
return 'fa-solid fa-spinner fa-spin';
}
const ETAPipe = value => {
if (value === null || 0 === value) {
return 'Live';
}
if (value < 60) {
return `${Math.round(value)}s`;
}
if (value < 3600) {
return `${Math.floor(value / 60)}m ${Math.round(value % 60)}s`;
}
const hours = Math.floor(value / 3600)
const minutes = value % 3600
return `${hours}h ${Math.floor(minutes / 60)}m ${Math.round(minutes % 60)}s`;
}
const speedPipe = value => {
if (value === null || 0 === value) {
return '0KB/s';
}
const k = 1024;
const dm = 2;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s', 'TB/s', 'PB/s', 'EB/s', 'ZB/s', 'YB/s'];
const i = Math.floor(Math.log(value) / Math.log(k));
return parseFloat((value / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
const percentPipe = value => {
if (value === null || 0 === value) {
return '00.00';
}
return parseFloat(value).toFixed(2);
}
const updateProgress = (item) => {
let string = '';
if (item.status == 'preparing') {
return 'Preparing';
}
if (item.status != null) {
string += item.percent && !item.is_live ? percentPipe(item.percent) + '%' : 'Live';
}
string += item.speed ? ' - ' + speedPipe(item.speed) : ' - Waiting..';
if (item.status != null && item.eta) {
string += ' - ' + ETAPipe(item.eta);
}
return string;
}
const confirmDelete = (item) => {
if (!confirm(`Are you sure you want to delete (${item.title})?`)) {
return false;
}
emit('deleteItem', 'queue', item._id);
return true;
}
</script>

54
ui/error.vue Normal file
View file

@ -0,0 +1,54 @@
<template>
<NuxtLayout name="error-layout">
<div>
<div class="columns is-multiline">
<div class="column is-12">
<h1 class="title is-4">
{{ error.statusCode }}<span v-if="error.statusMessage"> - {{ error.statusMessage }}</span>
</h1>
</div>
</div>
<div class="column is-12" v-if="error.message">
<div class="notification has-background-warning-90 has-text-dark">
<p>{{ error.message }}</p>
</div>
</div>
<div class="column is-12" v-if="error.stack">
<h2 class="title is-5 is-clickable" @click="showStacks = !showStacks">
<span class="icon-text">
<span class="icon">
<i v-if="showStacks" class="fas fa-arrow-up"></i>
<i v-else class="fas fa-arrow-down"></i>
</span>
<span>Stack trace</span>
</span>
</h2>
<pre v-if="showStacks"><code>{{ error.stack }}</code></pre>
</div>
<div class="column is-12">
<h2 class="title is-5">
<NuxtLink to="/">
<span class="icon-text">
<span class="icon"><i class="fas fa-home"></i></span>
<span>Back to Home</span>
</span>
</NuxtLink>
</h2>
</div>
</div>
</NuxtLayout>
</template>
<script setup>
defineProps({
error: {
type: Object,
required: true
}
})
const showStacks = ref(false)
</script>

135
ui/layouts/default.vue Normal file
View file

@ -0,0 +1,135 @@
<template>
<div id="main_container" class="container" :class="{'is-max-fullwidth': expandContainer}">
<nav class="navbar is-dark mb-5">
<div class="navbar-brand pl-5">
<NuxtLink class="navbar-item" to="/">
<span class="icon-text">
<span class="icon"><i class="fas fa-home"></i></span>
<span>Home</span>
</span>
</NuxtLink>
<a class="navbar-item is-hidden-tablet" id="top" href="#bottom">
<span class="icon"><i class="fas fa-arrow-down"></i></span>
</a>
<button class="navbar-burger burger" @click="showMenu = !showMenu">
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</button>
</div>
<div class="navbar-menu is-unselectable" :class="{'is-active':showMenu}">
<div class="navbar-end pr-3">
<div class="navbar-item">
<button class="button is-dark" @click="selectedTheme = 'light'" v-if="'dark' === selectedTheme"
v-tooltip="'Switch to light theme'">
<span class="icon has-text-warning"><i class="fas fa-sun"></i></span>
</button>
<button class="button is-dark" @click="selectedTheme = 'dark'" v-if="'light' === selectedTheme"
v-tooltip="'Switch to dark theme'">
<span class="icon"><i class="fas fa-moon"></i></span>
</button>
</div>
<div class="navbar-item">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh"></i></span>
</button>
</div>
</div>
</div>
</nav>
<NuxtPage />
<div class="columns is-multiline is-mobile mt-3">
<div class="column is-12 is-hidden-tablet has-text-centered">
<a href="#top" id="bottom" class="button">
<span class="icon"><i class="fas fa-arrow-up"></i>&nbsp;</span>
<span>Go to Top</span>
</a>
</div>
<div class="column is-6 is-9-mobile has-text-left"></div>
<div class="column is-6 is-4-mobile has-text-right">
v{{ VERSION }}
</div>
</div>
<NuxtNotifications position="top right" :speed="800" :ignoreDuplicates="true" :width="340" :pauseOnHover="true"/>
</div>
</template>
<script setup>
import {ref} from 'vue'
import 'assets/css/bulma.css'
import 'assets/css/style.css'
import 'assets/css/all.css'
import {useStorage} from '@vueuse/core'
import {notification} from '~/utils/index'
const runtimeConfig = useRuntimeConfig()
const selectedTheme = useStorage('theme', (() => window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')())
const showMenu = ref(false)
const VERSION = ref(runtimeConfig.public.version)
const expandContainer = ref(false)
const applyPreferredColorScheme = scheme => {
for (let s = 0; s < document.styleSheets.length; s++) {
for (let i = 0; i < document.styleSheets[s].cssRules.length; i++) {
try {
const rule = document.styleSheets[s].cssRules[i]
if (rule && rule.media && rule.media.mediaText.includes("prefers-color-scheme")) {
switch (scheme) {
case "light":
rule.media.appendMedium("original-prefers-color-scheme")
if (rule.media.mediaText.includes("light")) {
rule.media.deleteMedium("(prefers-color-scheme: light)")
}
if (rule.media.mediaText.includes("dark")) {
rule.media.deleteMedium("(prefers-color-scheme: dark)")
}
break
case "dark":
rule.media.appendMedium("(prefers-color-scheme: light)")
rule.media.appendMedium("(prefers-color-scheme: dark)")
if (rule.media.mediaText.includes("original")) {
rule.media.deleteMedium("original-prefers-color-scheme")
}
break
default:
rule.media.appendMedium("(prefers-color-scheme: dark)")
if (rule.media.mediaText.includes("light")) {
rule.media.deleteMedium("(prefers-color-scheme: light)")
}
if (rule.media.mediaText.includes("original")) {
rule.media.deleteMedium("original-prefers-color-scheme")
}
break
}
}
} catch (e) {
console.debug(e)
}
}
}
}
onMounted(async () => {
try {
applyPreferredColorScheme(selectedTheme.value)
} catch (e) {
}
})
watch(selectedTheme, value => {
try {
applyPreferredColorScheme(value)
} catch (e) {
}
})
const reloadPage = () => window.location.reload()
</script>

3
ui/layouts/empty.vue Normal file
View file

@ -0,0 +1,3 @@
<template>
<slot />
</template>

View file

@ -0,0 +1,21 @@
<template>
<div class="container">
<nav class="navbar is-dark mb-5">
<div class="navbar-brand pl-5">
<NuxtLink class="navbar-item" to="/">
<span class="icon-text">
<span class="icon"><i class="fas fa-home"></i></span>
<span>Home</span>
</span>
</NuxtLink>
</div>
</nav>
<slot />
</div>
</template>
<script setup>
import 'assets/css/bulma.css'
import 'assets/css/style.css'
import 'assets/css/all.css'
</script>

54
ui/nuxt.config.ts Normal file
View file

@ -0,0 +1,54 @@
import path from "path";
export default defineNuxtConfig({
ssr: false,
devtools: { enabled: false },
devServer: {
port: 8082,
host: "0.0.0.0",
},
runtimeConfig: {
public: {
domain: '/',
wss: ':8081',
version: '2.0.0',
}
},
app: {
buildAssetsDir: "assets",
head: {
"meta": [
{ "charset": "utf-8" },
{ "name": "viewport", "content": "width=device-width, initial-scale=1.0, maximum-scale=1.0" },
{ "name": "theme-color", "content": "#000000" }
],
link: [{ rel: 'icon', type: 'image/png', href: '/favicon.png' }]
},
pageTransition: { name: 'page', mode: 'out-in' }
},
router: {
options: {
linkActiveClass: "is-selected",
}
},
modules: [
'@pinia/nuxt',
'@vueuse/nuxt',
'floating-vue/nuxt',
'nuxt3-notifications',
],
nitro: {
output: {
publicDir: path.join(__dirname, 'exported')
}
},
telemetry: false,
compatibilityDate: "2024-07-13",
})

31
ui/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "nuxt-app",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"web-types": "./web-types.json",
"dependencies": {
"@pinia/nuxt": "^0.5.1",
"@vueuse/core": "^10.9.0",
"@vueuse/nuxt": "^10.9.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"cronstrue": "^2.49.0",
"floating-vue": "^5.2.2",
"moment": "^2.30.1",
"nuxt": "^3.11.2",
"nuxt3-notifications": "^1.2.0",
"pinia": "^2.1.7",
"plyr": "^3.7.8",
"vue": "^3.4.21",
"vue-router": "^4.3.0",
"socket.io-client": "^4.7.2"
},
"devDependencies": {}
}

109
ui/pages/index.vue Normal file
View file

@ -0,0 +1,109 @@
<template>
<div>
<Queue :config="config" :queue="downloading" @deleteItem="deleteItem" />
<History :config="config" :completed="completed" @deleteItem="deleteItem" @addItem="addItem"
@playItem="playItem" @archiveItem="archiveItem" />
</div>
</template>
<script setup>
import { io } from "socket.io-client";
const bus = useEventBus('item_added', 'show_form', 'task_edit')
const config = reactive({
isConnected: false,
app: {},
tasks: [],
})
const runtimeConfig = useRuntimeConfig()
const socket = ref()
const downloading = reactive({})
const completed = reactive({})
const cli_output = ref([])
const cli_isLoading = ref(false)
useHead({ title: 'Index' })
onMounted(() => {
socket.value = io(runtimeConfig.public.wss, {
path: document.location.pathname + 'socket.io',
})
socket.value.on('connect', () => config.isConnected = true);
socket.value.on('disconnect', () => config.isConnected = false);
socket.value.on('initial_data', stream => {
const initialData = JSON.parse(stream);
config.app = initialData['config'];
config.tasks = initialData['tasks'];
config.directories = initialData['directories'];
for (const id in initialData['queue']) {
downloading[id] = initialData['queue'][id];
}
for (const id in initialData['done']) {
completed[id] = initialData['done'][id];
}
})
socket.value.on('added', stream => {
const item = JSON.parse(stream);
downloading[item._id] = item;
toast.success(`Item queued successfully: ${downloading[item._id]?.title}`);
});
socket.value.on('error', stream => {
const [item, error] = JSON.parse(stream);
toast.error(`${item?.id}: Error: ${error}`);
});
socket.value.on('completed', stream => {
const item = JSON.parse(stream);
if (item._id in downloading) {
delete downloading[item._id];
}
completed[item._id] = item;
});
socket.value.on('canceled', stream => {
const id = JSON.parse(stream);
if (false === (id in downloading)) {
return
}
toast.info('Download canceled: ' + downloading[id]?.title);
delete downloading[id];
});
socket.value.on('cleared', stream => {
const id = JSON.parse(stream);
if (false === (id in completed)) {
return;
}
delete completed[id];
});
socket.value.on("updated", stream => {
const data = JSON.parse(stream);
let dl = downloading[data._id] ?? {};
data.deleting = dl?.deleting;
downloading[data._id] = data;
});
socket.value.on("update", stream => {
const data = JSON.parse(stream);
if (false === (data._id in completed)) {
return;
}
completed[data._id] = data;
});
socket.value.on('cli_close', () => cli_isLoading.value = false);
socket.value.on('cli_output', s => cli_output.value.push(s));
});
</script>

33
ui/plugins/autoscroll.js Normal file
View file

@ -0,0 +1,33 @@
let observer;
const AUTO_SCROLL_EVENTS = ['scroll', 'mousewheel', 'touchmove'];
let events_handlers = [];
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('autoscroll', {
mounted: async el => {
let scrolledToBottom = true;
AUTO_SCROLL_EVENTS.forEach((ev, index) => {
events_handlers[index] = () => {
scrolledToBottom = (el.scrollHeight - el.scrollTop) - el.clientHeight <= 80;
}
el.addEventListener(ev, events_handlers[index], { passive: true });
});
observer = new MutationObserver(_ => {
if (false === scrolledToBottom) {
return;
}
el.scrollTop = el.scrollHeight;
});
observer.observe(el, { childList: true, subtree: true });
},
unmounted: el => {
AUTO_SCROLL_EVENTS.forEach((ev, index) => {
el.removeEventListener(ev, events_handlers[index], { passive: true });
});
observer.disconnect();
},
})
});

7
ui/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false
}
}

34
ui/utils/awaiter.ts Normal file
View file

@ -0,0 +1,34 @@
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// for non arrays, length is undefined, so != 0
const isNotTruthy = (val: any) => val === undefined || val === false || val === null || val.length === 0;
/**
* Waits for the test function to return a truthy value.
*
* @param test - The function to test
* @param timeout_ms - The maximum time to wait in milliseconds.
* @param frequency - The frequency to check the test function in milliseconds.
*
* @returns The result of the test function.
*/
export default async function awaiter(test: Function, timeout_ms: number = 20 * 1000, frequency: number = 200) {
if (typeof (test) != "function") {
throw new Error("test should be a function in awaiter(test, [timeout_ms], [frequency])")
}
const endTime: number = Date.now() + timeout_ms;
let result = test();
while (isNotTruthy(result)) {
if (Date.now() > endTime) {
return false;
}
await sleep(frequency);
result = test();
}
return result;
}

94
ui/utils/cache.js Normal file
View file

@ -0,0 +1,94 @@
/**
* Cache class
*
* @class
* @param {string} engine - The engine to use for caching. Either 'session' or 'local'.
* @throws {Error} If the engine is not supported.
*/
class Cache {
supportedEngines = ['session', 'local']
constructor(engine = 'session') {
if (!this.supportedEngines.includes(engine)) {
throw new Error(`Engine '${engine}' not supported.`)
}
if ('session' === engine) {
this.storage = window.sessionStorage
} else {
this.storage = window.localStorage
}
}
/**
* Set a value in the cache
*
* @param key {string}
* @param value {*}
* @param ttl {number|null} - Time to live in milliseconds. If null, the value will not expire.
*/
set(key, value, ttl = null) {
this.storage.setItem(key, JSON.stringify({value, ttl, time: Date.now()}))
}
/**
* Get a value from the cache
*
* @param key {string}
* @returns {any}
*/
get(key) {
const item = this.storage.getItem(key)
if (null === item) {
return null
}
try {
const {value, ttl, time} = JSON.parse(item)
if (null !== ttl && Date.now() - time > ttl) {
this.remove(key)
return null
}
return value
} catch (e) {
return item
}
}
/**
* Remove a value from the cache
*
* @param key {string}
*/
remove(key) {
this.storage.removeItem(key)
}
/**
* Clear the cache
*
* @param {function|null} filter - A filter function to remove only specific keys.
*/
clear(filter = null) {
if (null !== filter) {
Object.keys(this.storage ?? {}).filter(filter).forEach(k => this.storage.removeItem(k))
} else {
this.storage.clear()
}
}
/**
* Check if a key is in the cache
*
* @param key {string}
* @returns {boolean}
*/
has(key) {
return null !== this.get(key)
}
}
const useSessionCache = () => new Cache('session')
const useLocalCache = () => new Cache('local')
export {useSessionCache, useLocalCache}

444
ui/utils/index.js Normal file
View file

@ -0,0 +1,444 @@
import { useNotification } from '@kyvg/vue3-notification'
import { useStorage } from '@vueuse/core'
const { notify } = useNotification()
const AG_SEPARATOR = '.'
/**
* Get value from object or function
*
* @param {Function|*} obj
* @returns {*}
*/
const getValue = (obj) => 'function' === typeof obj ? obj() : obj
/**
* Get value from object or function and return default value if it's undefined or null
*
* @param {Object|Array} obj The object to get the value from.
* @param {string} path The path to the value.
* @param {*} defaultValue The default value to return if the path is not found.
*
* @returns {*} The value at the path or the default value.
*/
const ag = (obj, path, defaultValue = null) => {
const keys = path.split(AG_SEPARATOR)
let at = obj
for (let key of keys) {
if (typeof at === 'object' && null !== at && key in at) {
at = at[key]
} else {
return getValue(defaultValue)
}
}
return getValue(null === at ? defaultValue : at)
}
/**
* Set value in object by path
*
* @param {Object} obj The object to set the value in.
* @param {string} path The path to the value.
* @param {*} value The value to set.
*
* @returns {Object} The object with the value set.
*/
const ag_set = (obj, path, value) => {
const keys = path.split(AG_SEPARATOR)
let at = obj
while (keys.length > 0) {
if (keys.length === 1) {
if (typeof at === 'object' && at !== null) {
at[keys.shift()] = value
} else {
throw new Error(`Cannot set value at this path (${path}) because it's not an object.`)
}
} else {
const key = keys.shift()
if (!at[key]) {
at[key] = {}
}
at = at[key]
}
}
return obj
}
/**
* Wait for an element to be loaded in the DOM
*
* @param {string} sel The selector of the element.
* @param {Function} callback The callback function.
*
* @returns {void}
*/
const awaitElement = (sel, callback) => {
let interval = undefined
let $elm = document.querySelector(sel)
if ($elm) {
callback($elm, sel)
return
}
interval = setInterval(() => {
let $elm = document.querySelector(sel)
if ($elm) {
clearInterval(interval)
callback($elm, sel)
}
}, 200)
}
/**
* Display a notification
*
* @param {string} type The type of the notification.
* @param {string} title The title of the notification.
* @param {string} text The text of the notification.
* @param {number} duration The duration of the notification.
*
* @returns {void}
*/
const notification = (type, title, text, duration = 3000) => {
let classes = ''
const notificationType = type.toLowerCase()
switch (notificationType) {
case 'info':
default:
classes = 'has-background-info has-text-white'
break
case 'success':
classes = 'has-background-success has-text-white'
break
case 'warning':
classes = 'has-background-warning has-text-white'
break
case 'error':
case 'crit':
classes = 'has-background-danger has-text-white'
if (3000 === duration) {
duration = 10000
}
if ('crit' === notificationType) {
log_error(`${title} ${text}`)
}
break
}
return notify({ title, text, type: classes, duration })
}
/**
* Replace tags in text with values from context
*
* @param {string} text The text with tags
* @param {object} context The context with values
*
* @returns {string} The text with replaced tags
*/
const r = (text, context = {}) => {
const tagLeft = '{'
const tagRight = '}'
if (!text.includes(tagLeft) || !text.includes(tagRight)) {
return text
}
const pattern = new RegExp(`${tagLeft}([\\w_.]+)${tagRight}`, 'g')
const matches = text.match(pattern)
if (!matches) {
return text
}
let replacements = {}
matches.forEach(match => replacements[match] = ag(context, match.slice(1, -1), ''))
for (let key in replacements) {
text = text.replace(new RegExp(key, 'g'), replacements[key])
}
return text
}
const copyText = (str, notify = true) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(str).then(() => {
if (notify) {
notification('success', 'Success', 'Text copied to clipboard.')
}
}).catch((error) => {
console.error('Failed to copy.', error)
if (notify) {
notification('error', 'Error', 'Failed to copy to clipboard.')
}
})
return
}
const el = document.createElement('textarea')
el.value = str
document.body.appendChild(el)
el.select()
document.execCommand('copy')
document.body.removeChild(el)
if (notify) {
notification('success', 'Success', 'Text copied to clipboard.')
}
}
/**
* Dispatch custom event.
*
* @param {string} eventName The name of the event.
* @param {object} detail The detail object.
*
* @returns {Boolean} The return value of dispatchEvent.
*/
const dEvent = (eventName, detail = {}) => window.dispatchEvent(new CustomEvent(eventName, { detail }))
/**
* Make pagination
*
* @param {number} current The current page.
* @param {number} last The last page.
* @param {number} delta The delta.
*
* @returns {Array} The pagination array.
*/
const makePagination = (current, last, delta = 5) => {
let pagination = []
if (last < 2) {
return pagination
}
const strR = '-'.repeat(9 + `${last}`.length)
const left = current - delta, right = current + delta + 1
for (let i = 1; i <= last; i++) {
if (i === 1 || i === last || (i >= left && i < right)) {
if (i === left && i > 2) {
pagination.push({
page: 0, text: strR, selected: false,
})
}
pagination.push({
page: i, text: `Page #${i}`, selected: i === current
})
if (i === right - 1 && i < last - 1) {
pagination.push({
page: 0, text: strR, selected: false,
})
}
}
}
return pagination
}
/**
* Encodes a path string to be used in a URL
*
* @param {string} item - The path string to encode
*
* @returns {string} - The encoded path string
*/
const encodePath = item => {
// -- manually encode #
if (!item) {
return item
}
item = item.replace(/#/g, '%23')
return item.split('/').map(decodeURIComponent).map(encodeURIComponent).join('/')
}
/**
* Request content from the API. This function will automatically add the API token to the request headers.
* And prefix the URL with the API URL and path.
*
* @param {string} url - The URL to request
* @param {object} options - The request options
*
* @returns {Promise<Response>} - The response from the API
*/
const request = (url, options = {}) => {
const runtimeConfig = useRuntimeConfig()
const token = useStorage('token', null)
options = options || {}
options.method = options.method || 'GET'
options.headers = options.headers || {}
if (token && undefined === options.headers['Authorization']) {
options.headers['Authorization'] = 'Token ' + token.value
}
if (undefined === options.headers['Content-Type']) {
if (!(options?.body instanceof FormData)) {
options.headers['Content-Type'] = 'application/json'
}
}
if (undefined === options.headers['Accept']) {
options.headers['Accept'] = 'application/json'
}
return fetch(url.startsWith('/') ? runtimeConfig.public.domain + url : url, options)
}
/**
* Remove the ANSI colors from the text
*
* @param {string} text - The text to remove the colors from
*
* @returns {string} - The text without the colors.
*/
const removeANSIColors = text => text?.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '') ?? text
const dec2hex = dec => dec.toString(16).padStart(2, "0")
const makeId = len => Array.from(window.crypto.getRandomValues(new Uint8Array((len || 40) / 2)), dec2hex).join('')
const basename = (path, ext = '') => {
if (!path) {
return ''
}
const segments = path.replace(/\\/g, '/').split('/')
let base = segments.pop()
while (segments.length && base === '') {
base = segments.pop()
}
if (ext && base.endsWith(ext) && base !== ext) {
base = base.substring(0, base.length - ext.length)
}
return base
}
const dirname = filePath => {
const lastIndex = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
if (-1 === lastIndex) {
return '.'
}
if (0 === lastIndex) {
return filePath[0]
}
return filePath.substring(0, lastIndex)
}
const iTrim = (str, delim, position = 'both') => {
if (!str) {
return str
}
if (!delim) {
throw new Error('Delimiter is required')
}
if ("]" === delim) {
delim = "\\]"
}
if ("\\" === delim) {
delim = "\\\\"
}
if (['both', 'start'].includes(position)) {
str = str.replace(new RegExp("^[" + delim + "]+", "g"), "")
}
if (['both', 'end'].includes(position)) {
str = str.replace(new RegExp("[" + delim + "]+$", "g"), "")
}
return str
}
const eTrim = (str, delim) => iTrim(str, delim, 'end')
const sTrim = (str, delim) => iTrim(str, delim, 'start')
const ucFirst = str => (!str) ? str : str.charAt(0).toUpperCase() + str.slice(1)
/**
* Get query parameters from the URL
*
* @param {string} url - The URL to get the query parameters from
*
* @returns {Object} - The query parameters
*/
const getQueryParams = (url = window.location.search) => Object.fromEntries(new URLSearchParams(url).entries())
/**
* Make download URL.
*
* @param {Object} config
* @param {Object} item
* @param {string} base
*
* @returns {string} The download URL
*/
const makeDownload = (config, item, base = 'download') => {
let baseDir = 'download' === base ? `${base}/` : 'player/playlist/';
if (item.folder) {
item.folder = item.folder.replace(/#/g, '%23');
baseDir += item.folder + '/';
}
let url = config.app.url_host + encodePath(config.app.url_prefix + baseDir + item.filename);
return ('m3u8' === base) ? url + '.m3u8' : url;
}
/**
* Format a file size
*
* @param {number} bytes
* @param {number} decimals
*
* @returns {string} The formatted file size
*/
const formatBytes = (bytes, decimals = 2) => {
if (!+bytes) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`
}
export {
ag_set,
ag,
awaitElement,
notification,
copyText,
dEvent,
makePagination,
request,
r,
encodePath,
removeANSIColors,
makeId,
basename,
iTrim,
eTrim,
sTrim,
ucFirst,
dirname,
getQueryParams,
makeDownload,
formatBytes,
}

58
ui/web-types.json Normal file
View file

@ -0,0 +1,58 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"framework": "vue",
"js-types-syntax": "typescript",
"description-markup": "markdown",
"framework-config": {
"enable-when": {
"file-extensions": [
"vue"
]
}
},
"contributions": {
"html": {
"vue-directives": [
{
"name": "tooltip",
"description": "Create a tooltip for an element.",
"doc-url": "",
"attribute-value": {
"type": "string",
"required": true
},
"modifiers": [
{
"name": "top",
"description": "Position the tooltip at the top of the element."
},
{
"name": "bottom",
"description": "Position the tooltip at the bottom of the element."
},
{
"name": "left",
"description": "Position the tooltip at the left of the element."
},
{
"name": "right",
"description": "Position the tooltip at the right of the element."
}
]
},
{
"name": "autoscroll",
"description": "Automatically scroll to an element.",
"doc-url": ""
}
],
"vue-components": [
{
"name": "VTooltip",
"description": "Create more advanced tooltips.",
"doc-url": "https://floating-vue.starpad.dev/guide/component#tooltip"
}
]
}
}
}

5207
ui/yarn.lock Normal file

File diff suppressed because it is too large Load diff