Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • aura/dashboard
1 result
Show changes
Commits on Source (104)
Showing
with 2285 additions and 2030 deletions
......@@ -2,6 +2,9 @@ module.exports = {
root: true,
env: { node: true },
parser: 'vue-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser',
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
......
......@@ -95,7 +95,7 @@ variables:
},
}
EOF
make initialize run.prod
make initialize loaddata.sample run.prod
- name: autoradio/tank:unstable
alias: tank
entrypoint: ['/bin/bash']
......
Source diff could not be displayed: it is too large. Options to address this: view the blob.
......@@ -24,59 +24,64 @@
"@fullcalendar/interaction": "^6.1.8",
"@fullcalendar/timegrid": "^6.1.8",
"@fullcalendar/vue3": "^6.1.8",
"@headlessui/vue": "^1.7.14",
"@headlessui/vue": "^1.7.16",
"@rokoli/bnb": "^0.2.2",
"@vue/compat": "^3.3.4",
"@vueuse/core": "^9.13.0",
"axios": "^1.4.0",
"@vueuse/core": "^10.4.1",
"@vueuse/integrations": "^10.4.1",
"axios": "^1.5.0",
"bootstrap-vue": "^2.23.1",
"core-js": "^3.31.0",
"core-js": "^3.32.1",
"date-fns": "^2.30.0",
"decamelize": "^6.0.0",
"dompurify": "^3.0.3",
"dompurify": "^3.0.5",
"fast-sort": "^3.4.0",
"filesize": "^10.0.7",
"filesize": "^10.0.12",
"lodash": "^4.17.21",
"node-polyglot": "^2.5.0",
"oidc-client": "^1.11.5",
"pinia": "^2.1.3",
"pinia": "^2.1.6",
"sortablejs": "^1.15.0",
"vue": "^3.3.4",
"vue-router": "^4.2.2",
"vue-router": "^4.2.4",
"vue-select": "^4.0.0-beta.6",
"vue-toast-notification": "^3.1.1",
"vuejs-logger": "^1.5.5",
"vuex": "^4.1.0"
},
"devDependencies": {
"@iconify/json": "^2.2.78",
"@iconify/json": "^2.2.109",
"@playwright/test": "^1.30.0",
"@types/dompurify": "^3.0.2",
"@types/lodash": "^4.14.195",
"@types/node": "^18.16.18",
"@types/lodash": "^4.14.197",
"@types/node": "^20.5.7",
"@types/node-polyglot": "^2.4.2",
"@types/web-bluetooth": "^0.0.16",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
"@vitejs/plugin-vue": "^4.2.3",
"@types/sortablejs": "^1.15.2",
"@types/web-bluetooth": "^0.0.17",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-vue": "^4.3.4",
"@vue/eslint-config-typescript": "^11.0.3",
"autoprefixer": "^10.4.14",
"autoprefixer": "^10.4.15",
"babel-eslint": "^10.1.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-vue": "^9.14.1",
"eslint-config-prettier": "^8.10.0",
"eslint-plugin-vue": "^9.17.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.2",
"openapi-typescript": "^6.2.7",
"postcss": "^8.4.24",
"postcss-nesting": "^11.3.0",
"lint-staged": "^14.0.1",
"openapi-typescript": "^6.5.4",
"postcss": "^8.4.29",
"postcss-nesting": "^12.0.1",
"prettier": "^2.8.8",
"rollup-plugin-visualizer": "^5.9.2",
"sass": "^1.63.4",
"tailwindcss": "^3.3.2",
"typescript": "^4.9.5",
"unplugin-icons": "^0.16.3",
"unplugin-vue-components": "^0.24.1",
"vite": "^4.1.5",
"vue-tsc": "^1.6.5"
"sass": "^1.66.1",
"tailwindcss": "^3.3.3",
"typescript": "5.1",
"unplugin-icons": "^0.16.6",
"unplugin-vue-components": "^0.25.2",
"vite": "^4.4.9",
"vite-svg-loader": "^4.0.0",
"vue-tsc": "^1.8.8"
},
"homepage": "https://gitlab.servus.at/aura/dashboard",
"license": "AGPL-3.0-only"
......
<svg width="46" height="59" viewBox="0 0 46 59" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.17 56.3508H3.58681L2.81496 57.9065H0.337875L4.63986 49.6854H7.11694L11.4189 57.9065H8.94183L8.17 56.3508ZM4.38257 54.7473H7.38618L5.89037 51.7198L4.38257 54.7473Z" fill="#F9F9F9"/>
<path d="M14.9012 49.6854V54.3165C14.9012 54.6236 14.9271 54.9029 14.979 55.1542C15.0309 55.4015 15.1266 55.6149 15.2662 55.7944C15.4058 55.9699 15.6013 56.1075 15.8526 56.2072C16.1039 56.303 16.4309 56.3508 16.8338 56.3508C17.137 56.3508 17.4541 56.3089 17.7852 56.2252C18.1202 56.1414 18.4513 56.0297 18.7784 55.8901C19.1055 55.7505 19.4166 55.5909 19.7118 55.4114C20.0109 55.228 20.2782 55.0385 20.5135 54.843V49.6854H22.7274V57.9065H20.5135V56.7816C20.2343 56.9412 19.9371 57.1027 19.622 57.2663C19.3069 57.4258 18.9718 57.5714 18.6168 57.703C18.2618 57.8307 17.8849 57.9344 17.486 58.0142C17.0871 58.0979 16.6663 58.1398 16.2235 58.1398C15.6691 58.1398 15.1725 58.072 14.7337 57.9364C14.2949 57.8048 13.9239 57.6173 13.6208 57.374C13.3176 57.1267 13.0843 56.8295 12.9207 56.4825C12.7612 56.1314 12.6814 55.7405 12.6814 55.3097V49.6854H14.9012Z" fill="#F9F9F9"/>
<path d="M27.5259 57.9065H25.3061V49.6854H30.8706C31.4689 49.6854 31.9755 49.7473 32.3903 49.8709C32.8092 49.9946 33.1482 50.1701 33.4075 50.3974C33.6708 50.6248 33.8602 50.9001 33.9759 51.2232C34.0956 51.5423 34.1554 51.8993 34.1554 52.2942C34.1554 52.6412 34.1055 52.9443 34.0058 53.2036C33.9101 53.4629 33.7785 53.6863 33.6109 53.8738C33.4474 54.0572 33.2559 54.2128 33.0365 54.3404C32.8171 54.4681 32.5858 54.5738 32.3425 54.6576L35.035 57.9065H32.4382L29.9492 54.8789H27.5259V57.9065ZM31.9117 52.2822C31.9117 52.1226 31.8897 51.987 31.8459 51.8753C31.806 51.7636 31.7342 51.6739 31.6305 51.6061C31.5268 51.5343 31.3872 51.4824 31.2117 51.4505C31.0401 51.4186 30.8247 51.4026 30.5654 51.4026H27.5259V53.1617H30.5654C30.8247 53.1617 31.0401 53.1457 31.2117 53.1138C31.3872 53.0819 31.5268 53.032 31.6305 52.9642C31.7342 52.8924 31.806 52.8007 31.8459 52.689C31.8897 52.5773 31.9117 52.4417 31.9117 52.2821V52.2822Z" fill="#F9F9F9"/>
<path d="M43.597 57.9065V57.0987C43.3138 57.2543 43.0087 57.3919 42.6816 57.5116C42.3585 57.6312 42.0214 57.733 41.6704 57.8167C41.3194 57.9005 40.9564 57.9623 40.5815 58.0022C40.2105 58.0461 39.8355 58.068 39.4566 58.068C39.0138 58.068 38.585 58.0261 38.1702 57.9424C37.7593 57.8586 37.3944 57.723 37.0753 57.5355C36.7561 57.348 36.4989 57.1027 36.3034 56.7995C36.1119 56.4964 36.0162 56.1254 36.0162 55.6867C36.0162 55.1322 36.1678 54.6715 36.4709 54.3045C36.7741 53.9336 37.2368 53.6304 37.8591 53.3951C38.4813 53.1597 39.2671 52.9782 40.2165 52.8506C41.1698 52.7229 42.2967 52.6232 43.597 52.5514V52.3779C43.597 52.2423 43.5611 52.1087 43.4893 51.977C43.4175 51.8414 43.2859 51.7237 43.0944 51.624C42.907 51.5203 42.6477 51.4365 42.3166 51.3727C41.9895 51.3089 41.5707 51.277 41.0601 51.277C40.6572 51.277 40.2584 51.2989 39.8635 51.3428C39.4726 51.3827 39.0936 51.4385 38.7266 51.5103C38.3636 51.5821 38.0186 51.6659 37.6915 51.7616C37.3644 51.8574 37.0673 51.9571 36.8 52.0608V50.0863C37.0792 50.0145 37.3924 49.9447 37.7394 49.8769C38.0864 49.8091 38.4474 49.7512 38.8224 49.7034C39.1973 49.6515 39.5763 49.6116 39.9592 49.5837C40.3421 49.5518 40.7091 49.5358 41.0601 49.5358C41.7502 49.5358 42.3864 49.5936 42.9688 49.7093C43.5512 49.821 44.0518 49.9965 44.4706 50.2359C44.8934 50.4752 45.2225 50.7784 45.4578 51.1453C45.6972 51.5123 45.8168 51.9491 45.8168 52.4557V57.9064L43.597 57.9065ZM43.597 54.1549C42.7434 54.1869 42.0174 54.2328 41.4191 54.2926C40.8248 54.3524 40.3302 54.4223 39.9353 54.502C39.5404 54.5818 39.2292 54.6675 39.0019 54.7593C38.7785 54.851 38.611 54.9447 38.4993 55.0405C38.3876 55.1362 38.3178 55.2319 38.2899 55.3277C38.2659 55.4234 38.254 55.5112 38.254 55.591C38.254 55.6867 38.2799 55.7804 38.3318 55.8722C38.3836 55.9599 38.4734 56.0377 38.601 56.1055C38.7326 56.1733 38.9062 56.2272 39.1216 56.2671C39.337 56.3069 39.6082 56.3269 39.9353 56.3269C40.2983 56.3269 40.6533 56.2989 41.0003 56.243C41.3473 56.1872 41.6764 56.1114 41.9875 56.0157C42.2987 55.916 42.5899 55.8003 42.8611 55.6686C43.1363 55.533 43.3816 55.3894 43.597 55.2379L43.597 54.1549Z" fill="#F9F9F9"/>
<path d="M23.0769 0.497627C11.3737 0.497627 1.84152 10.0298 1.84152 21.733C1.84152 33.4362 11.3737 42.9693 23.0769 42.9693C34.7801 42.9693 44.3132 33.4362 44.3132 21.733C44.3132 10.0298 34.7801 0.497627 23.0769 0.497627V0.497627ZM23.0769 4.73374C32.4906 4.73374 40.0771 12.3193 40.0771 21.733C40.0771 31.1467 32.4906 38.7332 23.0769 38.7332C13.6633 38.7332 6.07767 31.1467 6.07767 21.733C6.07767 12.3193 13.6633 4.73374 23.0769 4.73374V4.73374ZM23.0769 5.82279C14.3028 5.82279 7.16669 12.9589 7.16669 21.733C7.16669 30.5071 14.3028 37.6442 23.0769 37.6442C31.851 37.6442 38.9881 30.5071 38.9881 21.733C38.9881 12.9589 31.851 5.82279 23.0769 5.82279ZM23.0769 8.06031C30.6415 8.06031 36.7506 14.1684 36.7506 21.733C36.7506 29.2976 30.6415 35.4057 23.0769 35.4057C15.5123 35.4057 9.40421 29.2976 9.40421 21.733C9.40421 14.1684 15.5123 8.06031 23.0769 8.06031Z" fill="white"/>
<svg width="46" height="59" viewBox="0 0 46 59" xmlns="http://www.w3.org/2000/svg">
<path d="M8.17 56.3508H3.58681L2.81496 57.9065H0.337875L4.63986 49.6854H7.11694L11.4189 57.9065H8.94183L8.17 56.3508ZM4.38257 54.7473H7.38618L5.89037 51.7198L4.38257 54.7473Z" fill="currentColor"/>
<path d="M14.9012 49.6854V54.3165C14.9012 54.6236 14.9271 54.9029 14.979 55.1542C15.0309 55.4015 15.1266 55.6149 15.2662 55.7944C15.4058 55.9699 15.6013 56.1075 15.8526 56.2072C16.1039 56.303 16.4309 56.3508 16.8338 56.3508C17.137 56.3508 17.4541 56.3089 17.7852 56.2252C18.1202 56.1414 18.4513 56.0297 18.7784 55.8901C19.1055 55.7505 19.4166 55.5909 19.7118 55.4114C20.0109 55.228 20.2782 55.0385 20.5135 54.843V49.6854H22.7274V57.9065H20.5135V56.7816C20.2343 56.9412 19.9371 57.1027 19.622 57.2663C19.3069 57.4258 18.9718 57.5714 18.6168 57.703C18.2618 57.8307 17.8849 57.9344 17.486 58.0142C17.0871 58.0979 16.6663 58.1398 16.2235 58.1398C15.6691 58.1398 15.1725 58.072 14.7337 57.9364C14.2949 57.8048 13.9239 57.6173 13.6208 57.374C13.3176 57.1267 13.0843 56.8295 12.9207 56.4825C12.7612 56.1314 12.6814 55.7405 12.6814 55.3097V49.6854H14.9012Z" fill="currentColor"/>
<path d="M27.5259 57.9065H25.3061V49.6854H30.8706C31.4689 49.6854 31.9755 49.7473 32.3903 49.8709C32.8092 49.9946 33.1482 50.1701 33.4075 50.3974C33.6708 50.6248 33.8602 50.9001 33.9759 51.2232C34.0956 51.5423 34.1554 51.8993 34.1554 52.2942C34.1554 52.6412 34.1055 52.9443 34.0058 53.2036C33.9101 53.4629 33.7785 53.6863 33.6109 53.8738C33.4474 54.0572 33.2559 54.2128 33.0365 54.3404C32.8171 54.4681 32.5858 54.5738 32.3425 54.6576L35.035 57.9065H32.4382L29.9492 54.8789H27.5259V57.9065ZM31.9117 52.2822C31.9117 52.1226 31.8897 51.987 31.8459 51.8753C31.806 51.7636 31.7342 51.6739 31.6305 51.6061C31.5268 51.5343 31.3872 51.4824 31.2117 51.4505C31.0401 51.4186 30.8247 51.4026 30.5654 51.4026H27.5259V53.1617H30.5654C30.8247 53.1617 31.0401 53.1457 31.2117 53.1138C31.3872 53.0819 31.5268 53.032 31.6305 52.9642C31.7342 52.8924 31.806 52.8007 31.8459 52.689C31.8897 52.5773 31.9117 52.4417 31.9117 52.2821V52.2822Z" fill="currentColor"/>
<path d="M43.597 57.9065V57.0987C43.3138 57.2543 43.0087 57.3919 42.6816 57.5116C42.3585 57.6312 42.0214 57.733 41.6704 57.8167C41.3194 57.9005 40.9564 57.9623 40.5815 58.0022C40.2105 58.0461 39.8355 58.068 39.4566 58.068C39.0138 58.068 38.585 58.0261 38.1702 57.9424C37.7593 57.8586 37.3944 57.723 37.0753 57.5355C36.7561 57.348 36.4989 57.1027 36.3034 56.7995C36.1119 56.4964 36.0162 56.1254 36.0162 55.6867C36.0162 55.1322 36.1678 54.6715 36.4709 54.3045C36.7741 53.9336 37.2368 53.6304 37.8591 53.3951C38.4813 53.1597 39.2671 52.9782 40.2165 52.8506C41.1698 52.7229 42.2967 52.6232 43.597 52.5514V52.3779C43.597 52.2423 43.5611 52.1087 43.4893 51.977C43.4175 51.8414 43.2859 51.7237 43.0944 51.624C42.907 51.5203 42.6477 51.4365 42.3166 51.3727C41.9895 51.3089 41.5707 51.277 41.0601 51.277C40.6572 51.277 40.2584 51.2989 39.8635 51.3428C39.4726 51.3827 39.0936 51.4385 38.7266 51.5103C38.3636 51.5821 38.0186 51.6659 37.6915 51.7616C37.3644 51.8574 37.0673 51.9571 36.8 52.0608V50.0863C37.0792 50.0145 37.3924 49.9447 37.7394 49.8769C38.0864 49.8091 38.4474 49.7512 38.8224 49.7034C39.1973 49.6515 39.5763 49.6116 39.9592 49.5837C40.3421 49.5518 40.7091 49.5358 41.0601 49.5358C41.7502 49.5358 42.3864 49.5936 42.9688 49.7093C43.5512 49.821 44.0518 49.9965 44.4706 50.2359C44.8934 50.4752 45.2225 50.7784 45.4578 51.1453C45.6972 51.5123 45.8168 51.9491 45.8168 52.4557V57.9064L43.597 57.9065ZM43.597 54.1549C42.7434 54.1869 42.0174 54.2328 41.4191 54.2926C40.8248 54.3524 40.3302 54.4223 39.9353 54.502C39.5404 54.5818 39.2292 54.6675 39.0019 54.7593C38.7785 54.851 38.611 54.9447 38.4993 55.0405C38.3876 55.1362 38.3178 55.2319 38.2899 55.3277C38.2659 55.4234 38.254 55.5112 38.254 55.591C38.254 55.6867 38.2799 55.7804 38.3318 55.8722C38.3836 55.9599 38.4734 56.0377 38.601 56.1055C38.7326 56.1733 38.9062 56.2272 39.1216 56.2671C39.337 56.3069 39.6082 56.3269 39.9353 56.3269C40.2983 56.3269 40.6533 56.2989 41.0003 56.243C41.3473 56.1872 41.6764 56.1114 41.9875 56.0157C42.2987 55.916 42.5899 55.8003 42.8611 55.6686C43.1363 55.533 43.3816 55.3894 43.597 55.2379L43.597 54.1549Z" fill="currentColor"/>
<path d="M23.0769 0.497627C11.3737 0.497627 1.84152 10.0298 1.84152 21.733C1.84152 33.4362 11.3737 42.9693 23.0769 42.9693C34.7801 42.9693 44.3132 33.4362 44.3132 21.733C44.3132 10.0298 34.7801 0.497627 23.0769 0.497627V0.497627ZM23.0769 4.73374C32.4906 4.73374 40.0771 12.3193 40.0771 21.733C40.0771 31.1467 32.4906 38.7332 23.0769 38.7332C13.6633 38.7332 6.07767 31.1467 6.07767 21.733C6.07767 12.3193 13.6633 4.73374 23.0769 4.73374V4.73374ZM23.0769 5.82279C14.3028 5.82279 7.16669 12.9589 7.16669 21.733C7.16669 30.5071 14.3028 37.6442 23.0769 37.6442C31.851 37.6442 38.9881 30.5071 38.9881 21.733C38.9881 12.9589 31.851 5.82279 23.0769 5.82279ZM23.0769 8.06031C30.6415 8.06031 36.7506 14.1684 36.7506 21.733C36.7506 29.2976 30.6415 35.4057 23.0769 35.4057C15.5123 35.4057 9.40421 29.2976 9.40421 21.733C9.40421 14.1684 15.5123 8.06031 23.0769 8.06031Z" fill="currentColor" />
</svg>
<template>
<div id="app" :key="locale" class="tw-flex tw-flex-col tw-min-h-screen">
<AppNavbar :modules="modules" />
<div class="tw-flex-1 tw-flex tw-my-8">
<RouterView v-if="authStore.steeringUser" />
<Home v-else :modules="modules" />
</div>
<AppFooter :modules="footerModules" />
<div :key="locale" class="app tw-flex tw-flex-col md:tw-grid">
<ABreadcrumbs
v-if="navStore.breadcrumbs.length > 0"
class="tw-px-6 tw-border-solid tw-border-gray-200 tw-border-0 tw-border-b"
:breadcrumbs="navStore.breadcrumbs"
style="grid-area: breadcrumbs"
/>
<main class="tw-h-full tw-p-6" style="grid-area: main">
<RouterView v-if="authStore.steeringUser" class="tw-h-full" />
<Home v-else :modules="[]" />
</main>
<ANavSidebar style="grid-area: nav" />
<div id="sidebar-right" style="grid-area: slot"></div>
</div>
<AppFooter />
</template>
<script lang="ts" setup>
import AppNavbar from './components/Navbar.vue'
import AppFooter from './components/Footer.vue'
import Home from './Pages/Home.vue'
import { useI18n } from '@/i18n'
import { useAuthStore } from '@/stores/auth'
import { computedIter } from '@/util'
import ANavSidebar from '@/components/nav/ANavSidebar.vue'
import { useNavStore } from '@/stores/nav'
import ABreadcrumbs from '@/components/nav/ABreadcrumbs.vue'
const { locale, t } = useI18n()
const { locale } = useI18n()
const authStore = useAuthStore()
const footerModules = computedIter(function* () {
if (authStore.isSuperuser) {
yield {
slug: 'settings',
title: t('navigation.settings'),
}
}
})
const modules = computedIter(function* () {
if (authStore.currentUser) {
yield {
icon: '/assets/shows.svg',
slug: 'shows',
title: t('navigation.shows'),
}
yield {
icon: '/assets/files.svg',
slug: 'files',
title: t('navigation.filesPlaylists'),
}
}
if (authStore.isSuperuser) {
yield {
icon: '/assets/calendar.svg',
slug: 'calendar',
title: t('navigation.calendar'),
}
}
})
authStore.init()
const navStore = useNavStore()
</script>
<style scoped>
.app {
min-height: calc(100dvh - 40px);
grid-template-areas: 'nav breadcrumbs breadcrumbs' 'nav main slot';
grid-template-columns: 360px minmax(320px, 1200px) auto;
grid-template-rows: min-content minmax(0, 1fr);
}
</style>
......@@ -78,11 +78,11 @@
<template #cell(source)="data">
<span v-if="data.item.file">
<span class="tw-font-bold">{{
getFileTitleForPlaylist(data.item.file.showName, data.item.file.id)
getFileTitleForPlaylist(data.item.file.show, data.item.file.id)
}}</span
><br />
<span class="tw-text-gray-700"
>(file://{{ data.item.file.showName }}/{{ data.item.file.id }})</span
>(file://{{ data.item.file.show }}/{{ data.item.file.id }})</span
>
</span>
......@@ -108,7 +108,7 @@
</span>
<span v-else-if="data.item.file">
{{ prettyNanoseconds(getFileById(data.item.file.id).duration) }}
{{ secondsToDurationString(getFileById(data.item.file.id).duration) }}
</span>
<span
......@@ -117,7 +117,7 @@
class="tw-underline hover:tw-no-underline tw-cursor-pointer"
@click="toggleDurationField(data.index)"
>
{{ prettyNanoseconds(data.item.duration) }}
{{ secondsToDurationString(data.item.duration) }}
</span>
<span v-else>
......@@ -189,10 +189,10 @@
<b-dropdown-item
v-for="(file, index) in files"
:key="index"
@click="addPlaylistItemFile(file.showName, file.id)"
@click="addPlaylistItemFile(file.show, file.id)"
>
{{ file.id }}: {{ file.metadata.title ? file.metadata.title : '' }} ({{
prettyNanoseconds(file.duration)
formatSeconds(file.duration)
}}, {{ prettyFileSize(file.size) }}, {{ file.source.uri }})
</b-dropdown-item>
</b-dropdown>
......@@ -226,7 +226,7 @@ import Vue from 'vue'
import { mapGetters } from 'vuex'
import prettyDate from '@/mixins/prettyDate'
import { filesize } from 'filesize'
import { sanitizeHTML } from '../util'
import { sanitizeHTML, secondsToDurationString } from '../util'
export default {
mixins: [prettyDate],
......@@ -298,18 +298,13 @@ export default {
const totalDuration = this.playlistEditor.entries.reduce((acc, entry) => {
const newDuration = entry.file
? acc + this.durationInSeconds(this.getFileById(entry.file.id).duration)
: acc + this.durationInSeconds(entry.duration)
? acc + this.getFileById(entry.file.id).duration
: acc + entry.duration
if (Number.isNaN(newDuration)) {
return acc
}
return newDuration
return Number.isNaN(newDuration) ? acc : newDuration
}, 0)
const durationInNanoseconds = totalDuration * 1000 * 1000 * 1000
return this.prettyNanoseconds(durationInNanoseconds)
return this.formatSeconds(totalDuration)
},
...mapGetters({
......@@ -326,14 +321,14 @@ export default {
},
methods: {
secondsToDurationString,
sanitizeHTML,
// Checks if the duration entered is in a valid format and updates the corresponding
// entrys data in the playlist.
checkAndUpdateDuration() {
const regex = /^\d{2}:\d{2}$/
const entryIndex = this.playlistEditor.durationField
const entry = this.playlistEditor.entries[entryIndex]
let duration = this.playlistEditor.newDuration
const duration = this.playlistEditor.newDuration
this.$refs.durationField.setCustomValidity('')
......@@ -352,11 +347,7 @@ export default {
return
}
if (regex.test(duration)) {
duration = `00:${duration}`
}
Vue.set(entry, 'duration', this.hmsToNanoseconds(duration))
Vue.set(entry, 'duration', this.hmsToSeconds(duration))
Vue.set(this.playlistEditor.entries, entryIndex, entry)
this.playlistEditor.durationField = false
......@@ -368,7 +359,7 @@ export default {
const entry = this.playlistEditor.entries[id]
if (entry && entry.duration) {
this.playlistEditor.newDuration = this.prettyNanoseconds(entry.duration)
this.playlistEditor.newDuration = this.formatSeconds(entry.duration)
}
this.playlistEditor.durationField = id
......@@ -474,7 +465,7 @@ export default {
const entry = {}
if (playlist.entries[i].file) {
entry.file = {}
entry.file.showName = playlist.entries[i].file.showName
entry.file.show = playlist.entries[i].file.show
entry.file.id = playlist.entries[i].file.id
} else {
entry.uri = playlist.entries[i].uri
......
<template>
<b-container class="tw-flex tw-flex-col">
<div class="tw-flex tw-flex-col tw-h-full">
<PageHeader :title="$t('navigation.calendar')">
<b-button-group v-if="selectedShow">
<b-button :variant="view === 'day' ? 'primary' : 'secondary'" @click="view = 'day'">
......@@ -17,8 +17,12 @@
</div>
</template>
<auth-wall v-else-if="selectedShow" class="tw-flex-1">
<b-alert :variant="conflictCount > 0 ? 'danger' : 'success'" :show="conflictMode">
<auth-wall v-else-if="selectedShow" class="tw-flex-1 tw-flex tw-flex-col">
<b-alert
class="tw-flex-none"
:variant="conflictCount > 0 ? 'danger' : 'success'"
:show="conflictMode"
>
<div v-if="conflictMode">
<h4>{{ $t('conflictResolution.title') }}</h4>
<p
......@@ -74,9 +78,9 @@
</div>
</b-alert>
<server-errors :errors="serverErrors" />
<server-errors class="tw-flex-none" :errors="serverErrors" />
<div class="tw-h-full">
<div class="tw-flex-1 tw-basis-full">
<KeepAlive>
<div v-if="view === 'week'" class="tw-h-full">
<FullCalendar ref="calendar" :options="calendarConfig" />
......@@ -106,7 +110,7 @@
@update="loadTimeslots()"
/>
</auth-wall>
</b-container>
</div>
</template>
<script>
......
<template>
<b-container>
<PageHeader :title="$t('filePlaylistManager.title')" />
<div>
<UnderConstruction class="tw-top-0 tw-right-0">
<template #title>
<p>Die komplette bisherige Medienseite wird aufgelöst.</p>
<p>
Stattdessen wird es möglich sein, eine Playlist direkt innerhalb der jeweiligen Sendung zu
bearbeiten bzw. neu anzulegen.
</p>
</template>
<template v-if="!loaded.shows">
<div class="tw-text-center">
{{ $t('loading') }}
</div>
</template>
<PageHeader :title="$t('filePlaylistManager.title')" />
<template v-else-if="selectedShow">
<jumbotron />
<template v-if="!loaded.shows">
<div class="tw-text-center">
{{ $t('loading') }}
</div>
</template>
<!-- All the UI for uploading and editing files is only shown if the user
<template v-else-if="selectedShow">
<jumbotron />
<!-- All the UI for uploading and editing files is only shown if the user
choose to edit files in the jumbotron above -->
<div v-if="mode === 'files'">
<files />
</div>
<div v-if="mode === 'files'">
<files />
</div>
<!-- All the UI for creating and editing playlists is only shown if the user
<!-- All the UI for creating and editing playlists is only shown if the user
choose to edit playlists in the jumbotron above -->
<div v-if="mode === 'playlists'">
<playlists />
</div>
</template>
</b-container>
<div v-if="mode === 'playlists'">
<playlists />
</div>
</template>
</UnderConstruction>
</div>
</template>
<script>
......@@ -32,9 +42,11 @@ import jumbotron from '../components/filemanager/Jumbotron.vue'
import files from '../components/filemanager/Files.vue'
import playlists from '../components/filemanager/Playlists.vue'
import PageHeader from '@/components/PageHeader.vue'
import UnderConstruction from '@/components/UnderConstruction.vue'
export default {
components: {
UnderConstruction,
PageHeader,
jumbotron: jumbotron,
files: files,
......
<template>
<b-container class="tw-self-center">
<div>
<div v-if="authStore.currentUser && authStore.steeringUser">
<div class="tw-text-center tw-mb-8 sm:tw-mb-16 md:tw-mb-32">
<img src="/assets/logo.svg" alt="AURA Logo" class="tw-w-2/3 lg:tw-w-1/3" />
......@@ -31,7 +31,7 @@
{{ t('auth.signIn') }}
</b-button>
</div>
</b-container>
</div>
</template>
<script lang="ts" setup>
......
<template>
<div>
<PageHeader :title="t('myShows.title')">
<AddShowButton />
</PageHeader>
<div class="tw-flex tw-gap-6 tw-justify-end tw-items-center tw-mb-6">
<Tag v-if="displayMode === 'grid'" class="tw-self-stretch tw-justify-self-start tw-px-3">
{{
t('myShows.showCount', {
count: gridShowResultList.reduce((acc, cur) => acc + cur.items.length, 0),
total: gridShowResultList[0]?.count ?? 0,
})
}}
</Tag>
<Tag v-if="isLoading" role="alert" aria-live="polite" class="tw-px-3 tw-self-stretch">
<span class="tw-flex tw-gap-2 tw-items-center">
<Loading class="tw-h-1" />
{{ t('loadingData', { items: t('show.plural') }) }}
</span>
</Tag>
<span class="tw-mr-auto" />
<FormGroup class="tw-m-0">
<template #iconLeft="attrs">
<icon-system-uicons-search v-bind="attrs" />
</template>
<template #default="attrs">
<input
v-model="searchTerm"
v-bind="attrs"
:aria-label="t('myShows.searchLabel')"
:placeholder="t('myShows.searchPlaceholder')"
/>
</template>
</FormGroup>
<button
class="btn btn-default tw-flex tw-items-center tw-gap-2"
@click="orderFilterDialog.open()"
>
<icon-system-uicons-sort />
{{ t('myShows.sortShows') }}
</button>
<RadioGroup v-model="displayMode" :choices="['table', 'grid']" name="display-mode">
<template #icon="{ value }">
<icon-system-uicons-table-header v-if="value === 'table'" />
<icon-system-uicons-grid-small v-else-if="value === 'grid'" />
</template>
</RadioGroup>
<AEditDialog ref="orderFilterDialog" :title="t('myShows.showOrder')" class="tw-w-min">
<OrderFilter
v-model="order"
translate-base="showFilter.order.choices"
:choices="[
'id',
'slug',
{ name: 'is_active', directions: ['desc', 'asc'] },
{ name: 'updated_at', directions: ['desc', 'asc'] },
'updated_by',
{ name: 'is_owner', directions: ['desc', 'asc'] },
]"
/>
<template #footer="{ close }">
<button type="button" class="btn btn-default tw-min-w-[100px]" @click="close">
{{ t('ok') }}
</button>
</template>
</AEditDialog>
</div>
<ShowListTable v-if="displayMode === 'table'" :shows="tableShowResult">
<template #footer>
<div
v-if="tableShowResult.count > 0 || !isLoading"
class="tw-flex tw-items-center tw-px-6 tw-mt-3 tw-py-3 tw-border-0 tw-border-t tw-border-solid tw-border-gray-200 empty:tw-hidden"
>
<PaginationRange v-if="tableShowResult.count > 0" :pagination-data="tableShowResult" />
<FormGroup v-slot="attrs" class="tw-ml-auto tw-m-0 tw-mr-9 last:tw-mr-0">
<label class="tw-flex tw-items-center tw-gap-3 tw-m-0">
<span>{{ t('myShows.itemsPerPage') }}</span>
<input
v-model.lazy="tableShowsPerPage"
type="number"
min="1"
step="1"
v-bind="attrs"
class="tw-w-[80px] tw-self-center"
/>
</label>
</FormGroup>
<Pagination
v-model="tableShowsPage"
:items-per-page="tableShowsPerPage"
:count="tableShowResult.count"
/>
</div>
</template>
</ShowListTable>
<ShowListGrid
v-else
:shows="gridShowResultList"
:has-more="gridShowResult.hasNext"
:split="order[0] === '-is_owner'"
@load-more="loadMore"
/>
</div>
</template>
<script lang="ts" setup>
import { PaginatedListResult, usePaginatedList } from '@rokoli/bnb/drf'
import { useStorage } from '@vueuse/core'
import { computed, Ref, ref, watch } from 'vue'
import { useI18n } from '@/i18n'
import { computedDebounced } from '@/util'
import { Show } from '@/types'
import { useShowStore } from '@/stores/shows'
import PageHeader from '@/components/PageHeader.vue'
import ShowListTable from '@/components/shows/ShowListTable.vue'
import FormGroup from '@/components/generic/FormGroup.vue'
import ShowListGrid from '@/components/shows/ShowListGrid.vue'
import RadioGroup from '@/components/generic/RadioGroup.vue'
import Loading from '@/components/generic/Loading.vue'
import Tag from '@/components/generic/Tag.vue'
import AEditDialog from '@/components/generic/AEditDialog.vue'
import OrderFilter from '@/components/OrderFilter.vue'
import PaginationRange from '@/components/generic/PaginationRange.vue'
import Pagination from '@/components/generic/Pagination.vue'
import AddShowButton from '@/components/shows/AddShowButton.vue'
const TABLE_SHOWS_PER_PAGE = 12
const GRID_SHOWS_PER_PAGE = 10
type DisplayMode = 'grid' | 'table'
type OrderField = 'is_owner' | 'is_active' | 'slug' | 'updatedAt' | 'updatedBy' | 'id'
type ShowOrder = OrderField | `-${OrderField}`
const { t } = useI18n()
const { listIsolated } = useShowStore()
const orderFilterDialog = ref()
const searchTerm = ref('')
const debouncedSearchTerm = computedDebounced(searchTerm, (q: string) => (q.trim() ? 0.3 : 0))
const order = useStorage<ShowOrder[]>('aura:myShows:order', ['-is_owner', '-is_active', 'slug'])
const query = computed(
() =>
new URLSearchParams({
order: order.value.join(','),
search: debouncedSearchTerm.value.trim(),
}),
)
const displayMode: Ref<DisplayMode> = useStorage<DisplayMode>('aura:myShows:displayMode', 'grid')
const tableShowsPage = ref(1)
const tableShowsPerPage = useStorage('aura:myShows:tableShowsPerPage', TABLE_SHOWS_PER_PAGE)
const { result: tableShowResult, isLoading: isLoadingTableShows } = usePaginatedList(
listIsolated,
tableShowsPage,
tableShowsPerPage,
{ query },
)
const gridShowsPage = ref(1)
const gridShowsPerPage = useStorage('aura:myShows:gridShowsPerPage', GRID_SHOWS_PER_PAGE)
const gridShowResultMap = ref(new Map<number, PaginatedListResult<Show>>())
const gridShowResultList = computed(() => Array.from(gridShowResultMap.value.values()))
const { result: gridShowResult, isLoading: isLoadingGridShows } = usePaginatedList(
listIsolated,
gridShowsPage,
gridShowsPerPage,
{ query },
)
watch(query, () => {
gridShowsPage.value = 1
gridShowResultMap.value.clear()
})
watch(gridShowResult, (newShows: PaginatedListResult<Show>) => {
gridShowResultMap.value.set(newShows.page, newShows)
})
function loadMore() {
if (gridShowResult.value.hasNext) {
gridShowsPage.value += 1
}
}
const isLoading = computed(() => isLoadingTableShows.value || isLoadingGridShows.value)
</script>
<template>
<router-view v-if="show" :show="show" />
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useObjectFromStore } from '@rokoli/bnb/drf'
import { useShowStore } from '@/stores/shows'
const route = useRoute()
const showStore = useShowStore()
const { obj: show } = useObjectFromStore(() => parseInt(route.params.showId as string), showStore)
</script>
<template>
<div>
<PageHeader :title="t('navigation.show.basicData')" :lead="show.name" />
<UnderConstruction class="tw-top-2 tw-right-2 tw-text-xl">
<template #title>
<p>
Dieser Bereich wird demnächst zugunsten einzelner Eingabefelder (wie weiter unten zu
sehen) aufgelöst.
</p>
<p>
Der Button für die Deaktivierung der Sendereihe wandert in einen eigenen Bereich und
erhält eine Erklärung zu Bedeutung & Funktionsweise.
</p>
</template>
<ShowJumbotron />
</UnderConstruction>
<div class="tw-grid tw-gap-x-6 tw-grid-cols-1 md:tw-grid-cols-2 lg:tw-grid-cols-3">
<ShowMetaSimpleTypes />
<hr class="tw-col-span-full tw-w-full" />
<ShowMetaArrays />
<hr class="tw-col-span-full tw-w-full" />
<ShowMetaImages />
<ShowMetaOwners />
</div>
</div>
</template>
<script lang="ts" setup>
import ShowJumbotron from '../components/shows/Jumbotron.vue'
import ShowMetaSimpleTypes from '../components/shows/MetaSimpleTypes.vue'
import ShowMetaArrays from '../components/shows/MetaArrays.vue'
import ShowMetaOwners from '../components/shows/MetaOwners.vue'
import ShowMetaImages from '../components/shows/MetaImages.vue'
import { useStore } from 'vuex'
import PageHeader from '@/components/PageHeader.vue'
import { useAuthStore, useUserStore } from '@/stores/auth'
import { useI18n } from '@/i18n'
import { Show } from '@/types'
import UnderConstruction from '@/components/UnderConstruction.vue'
import { useBreadcrumbs } from '@/stores/nav'
const props = defineProps<{
show: Show
}>()
const authStore = useAuthStore()
const userStore = useUserStore()
const store = useStore()
const { t } = useI18n()
useBreadcrumbs(() => [
{ title: t('navigation.shows'), route: { name: 'shows' } },
{ title: props.show.name, route: { name: 'show', params: { showId: props.show.id.toString() } } },
t('navigation.show.basicData'),
])
store.dispatch('shows/fetchMetaArray', { property: 'types', onlyActive: true })
store.dispatch('shows/fetchMetaArray', {
property: 'fundingCategories',
onlyActive: true,
})
store.dispatch('shows/fetchMetaArray', { property: 'categories' })
store.dispatch('shows/fetchMetaArray', { property: 'topics' })
store.dispatch('shows/fetchMetaArray', { property: 'musicFocus' })
store.dispatch('shows/fetchMetaArray', { property: 'languages' })
store.dispatch('shows/fetchMetaArray', { property: 'hosts' })
if (authStore.isSuperuser) {
userStore.list()
}
</script>
<template>
<div class="tw-max-w-5xl">
<PageHeader :lead="show.name" :title="t('navigation.show.episodes')" />
<UnderConstruction class="tw-right-0 tw-top-0">
<template #title>
<p>
Der Dialog zur Bearbeitung der Shownotes wird demnächst auf eine separate Seite
ausgelagert.
</p>
<p>
Auch die Zuweisung der jeweiligen Playlist wird auf diese Unterseite verschoben bzw.
ergänzt, so dass die Inhalte der Playlist sich direkt dort bearbeiten lassen und kein
Wechsel zur Medienseite mehr notwendig ist.
</p>
</template>
<TimeSlotList :show="show" />
</UnderConstruction>
<ShowSchedules :show="show" />
</div>
</template>
<script setup lang="ts">
import { Show } from '@/types'
import TimeSlotList from '@/components/shows/TimeSlotList.vue'
import ShowSchedules from '@/components/shows/Schedules.vue'
import PageHeader from '@/components/PageHeader.vue'
import { useI18n } from '@/i18n'
import UnderConstruction from '@/components/UnderConstruction.vue'
import { useBreadcrumbs } from '@/stores/nav'
const props = defineProps<{
show: Show
}>()
const { t } = useI18n()
useBreadcrumbs(() => [
{ title: t('navigation.shows'), route: { name: 'shows' } },
{ title: props.show.name, route: { name: 'show', params: { showId: props.show.id.toString() } } },
t('navigation.show.episodes'),
])
</script>
<template>
<b-container>
<PageHeader :title="t('showManager.title')" />
<template v-if="!hasLoadedShows">
<div class="tw-text-center">
{{ t('loading') }}
</div>
</template>
<template v-else-if="selectedShow">
<!-- The jumbotron is used to display the name and description of the
currently selected show -->
<ShowJumbotron />
<ShowSchedules />
<TimeSlotList />
<SectionTitle class="tw-mb-4">{{ t('showManager.generalSettings') }}</SectionTitle>
<div class="tw-grid tw-gap-x-6 tw-grid-cols-3">
<ShowMetaSimpleTypes />
<hr class="tw-col-span-3 tw-w-full" />
<ShowMetaArrays />
<hr class="tw-col-span-3 tw-w-full" />
<ShowMetaImages />
<ShowMetaOwners />
</div>
</template>
</b-container>
</template>
<script setup>
import ShowJumbotron from '../components/shows/Jumbotron.vue'
import ShowSchedules from '../components/shows/Schedules.vue'
import ShowMetaSimpleTypes from '../components/shows/MetaSimpleTypes.vue'
import ShowMetaArrays from '../components/shows/MetaArrays.vue'
import ShowMetaOwners from '../components/shows/MetaOwners.vue'
import ShowMetaImages from '../components/shows/MetaImages.vue'
import { useStore } from 'vuex'
import PageHeader from '@/components/PageHeader.vue'
import { useAuthStore, useUserStore } from '@/stores/auth'
import { computed, watchEffect } from 'vue'
import { useI18n } from '@/i18n'
import TimeSlotList from '@/components/shows/TimeSlotList.vue'
import SectionTitle from '@/components/generic/SectionTitle.vue'
const authStore = useAuthStore()
const userStore = useUserStore()
const store = useStore()
const { t } = useI18n()
const hasLoadedShows = computed(() => store.state.shows.loaded.shows)
const selectedShow = computed(() => store.getters['shows/selectedShow'])
function loadShowInfos() {
store.dispatch('shows/fetchShows', {
callback: () => {
if (!selectedShow.value) {
return
}
store.dispatch('playlists/fetch', { showSlug: selectedShow.value.slug })
},
})
store.dispatch('shows/fetchMetaArray', { property: 'types', onlyActive: true })
store.dispatch('shows/fetchMetaArray', {
property: 'fundingCategories',
onlyActive: true,
})
store.dispatch('shows/fetchMetaArray', { property: 'categories' })
store.dispatch('shows/fetchMetaArray', { property: 'topics' })
store.dispatch('shows/fetchMetaArray', { property: 'musicFocus' })
store.dispatch('shows/fetchMetaArray', { property: 'languages' })
store.dispatch('shows/fetchMetaArray', { property: 'hosts' })
if (authStore.isSuperuser) {
userStore.list()
}
}
watchEffect(() => {
if (authStore.steeringUser) {
loadShowInfos()
}
})
</script>
import { defineStore } from 'pinia'
import { computed, ComputedRef, readonly, ref, Ref, shallowReadonly, watchEffect } from 'vue'
import { merge } from 'lodash'
export const SERVER_ERRORS_GLOBAL: unique symbol = Symbol('ERRORS_GLOBAL')
export type ID = number | string
type ErrorDetail = {
message: string
code: string
}
type ErrorMap = Record<string, ErrorDetail[]> & {
[SERVER_ERRORS_GLOBAL]: ErrorDetail[]
}
export class APIError extends Error {
constructor(message: string) {
super(message)
}
}
export class APIResponseError extends APIError {
response: Response
data: unknown
constructor(message: string, response: Response, data: unknown) {
super(message)
this.response = response
this.data = data
}
}
type APIObject = { id: ID }
type ExtendableAPI<T extends APIObject> = {
itemMap: Ref<Map<ID, T>>
endpoint: URLBuilder
maybeRaiseResponse: (res: Response) => Promise<void>
createRequest: (
url: string,
customRequestData: RequestInit | undefined,
defaultRequestData?: RequestInit | undefined,
) => Request
}
type APIStoreOptions<T extends APIObject> = {
getRequestDefaults?: () => RequestInit
}
type PaginatedResults<T> = {
count: number
next: string | null
previous: string | null
results: T[]
}
type URLToken = string | number
type URLBuilder = (...subPaths: URLToken[] | [...URLToken[], URLToken | URLSearchParams]) => string
type PrefixableURLBuilder = URLBuilder & {
prefix: (...prefixes: URLToken[]) => PrefixableURLBuilder
}
function createURLBuilder(basepath: string, useTrailingSlash = true): PrefixableURLBuilder {
// Strip all trailing slashes from the basepath.
// We handle slashes when building URLs.
basepath = basepath.replace(/\/*$/, '')
const buildURL: PrefixableURLBuilder = (...subPaths) => {
let params
if (subPaths.at(-1) instanceof URLSearchParams) {
params = subPaths.pop()
}
if (subPaths.some((path) => String(path).includes('/'))) {
throw new Error('Subpaths must not contain slashes')
}
const subPath = subPaths.length > 0 ? '/' + subPaths.join('/') : ''
const url = basepath + subPath + (useTrailingSlash ? '/' : '')
return params ? url + `?${params}` : url
}
buildURL.prefix = function (...prefixes: URLToken[]) {
return createURLBuilder(buildURL(...prefixes), useTrailingSlash)
}
return buildURL
}
import { createURLBuilder } from '@rokoli/bnb/common'
import {
APICreate,
APIListUnpaginated,
APIRemove,
APIRetrieve,
APIUpdate,
createExtendableAPI,
} from '@rokoli/bnb/drf'
import { APIObject, APIStoreOptions, URLBuilder } from '@rokoli/bnb'
export const createTankURL = createURLBuilder(import.meta.env.VUE_APP_API_TANK, false)
export const createSteeringURL = createURLBuilder(import.meta.env.VUE_APP_API_STEERING)
export function createExtendableAPI<T extends APIObject>(
endpoint: URLBuilder,
options?: APIStoreOptions<T>,
) {
const itemMap = ref<Map<ID, T>>(new Map())
const items = computed<T[]>(() => Array.from(itemMap.value.values()))
const error = ref<Error>()
async function maybeRaiseResponse(response: Response) {
if (!response.ok) {
let data = null
try {
data = await response.json()
} catch (e) {
// pass
}
const _error = new APIResponseError(
`Failure response when executing when interacting with ${response.url}`,
response,
data,
)
error.value = _error
throw _error
}
}
function createRequest(
url: string,
customRequestData: RequestInit | undefined,
defaultRequestData?: RequestInit | undefined,
) {
return new Request(
url,
merge(
options?.getRequestDefaults?.() ?? {},
defaultRequestData ?? {},
customRequestData ?? {},
),
)
}
function reset() {
itemMap.value.clear()
}
return {
base: {
error: readonly(error),
items,
itemMap: shallowReadonly(itemMap),
reset,
},
createRequest,
endpoint,
maybeRaiseResponse,
itemMap,
}
}
export function APIListUnpaginated<T extends APIObject>(api: ExtendableAPI<T>) {
async function list(requestInit?: RequestInit): Promise<T[]> {
const res = await fetch(api.createRequest(api.endpoint(), requestInit))
await api.maybeRaiseResponse(res)
const items: T[] = await res.json()
for (const item of items) {
api.itemMap.value.set(item.id, item)
}
return items
}
return { list }
}
export function APIListPaginated<T extends APIObject>(
api: ExtendableAPI<T>,
mode: 'offset' | 'page' = 'offset',
limit = 20,
) {
const count = ref<number>(0)
const currentPage = ref<number>(1)
const nextPage = ref<string | null>(null)
const hasNext = computed(() => nextPage.value !== null)
const previousPage = ref<string | null>(null)
const hasPrevious = computed(() => previousPage.value !== null)
async function list(page?: number, requestInit?: RequestInit): Promise<T[]> {
page = page ?? currentPage.value
const query = new URLSearchParams()
if (mode === 'offset') {
query.set('limit', limit.toString())
query.set('offset', ((page - 1) * limit).toString())
} else {
query.set('pageSize', limit.toString())
query.set('page', page.toString())
}
const res = await fetch(api.createRequest(api.endpoint(query), requestInit))
await api.maybeRaiseResponse(res)
const data: PaginatedResults<T> = await res.json()
currentPage.value = page
count.value = data.count
nextPage.value = data.next
previousPage.value = data.previous
for (const item of data.results) {
api.itemMap.value.set(item.id, item)
}
return data.results
}
function reset() {
api.itemMap.value.clear()
currentPage.value = 1
count.value = 0
nextPage.value = null
previousPage.value = null
}
return {
list,
reset,
hasNext,
hasPrevious,
count: readonly(count),
currentPage: readonly(currentPage),
}
}
export function APIRetrieve<T extends APIObject>(api: ExtendableAPI<T>) {
async function retrieve(
id: ID,
requestInit?: RequestInit,
options?: { useCached?: boolean },
): Promise<T | null> {
if (options?.useCached && api.itemMap.value.has(id)) {
return api.itemMap.value.get(id) as T
}
const res = await fetch(api.createRequest(api.endpoint(id.toString()), requestInit))
if (res.status === 404) {
api.itemMap.value.delete(id)
return null
} else {
await api.maybeRaiseResponse(res)
const obj: T = await res.json()
api.itemMap.value.set(obj.id, obj)
return obj
}
}
return { retrieve }
}
export function APIUpdate<T extends APIObject, TData = Partial<T>>(api: ExtendableAPI<T>) {
async function update(id: ID, data: TData | FormData, requestInit?: RequestInit): Promise<T> {
const res = await fetch(
api.createRequest(api.endpoint(id.toString()), requestInit, {
method: 'PUT',
headers: data instanceof FormData ? undefined : { 'Content-Type': 'application/json' },
body: data instanceof FormData ? data : JSON.stringify(data),
}),
)
await api.maybeRaiseResponse(res)
const obj: T = await res.json()
api.itemMap.value.set(obj.id, obj)
return obj
}
return { update }
}
export function APICreate<T extends APIObject, TData = Partial<Omit<T, 'id'>>>(
api: ExtendableAPI<T>,
) {
async function create(data: TData | FormData, requestInit?: RequestInit): Promise<T> {
const res = await fetch(
api.createRequest(api.endpoint(), requestInit, {
method: 'POST',
headers: data instanceof FormData ? undefined : { 'Content-Type': 'application/json' },
body: data instanceof FormData ? data : JSON.stringify(data),
}),
)
await api.maybeRaiseResponse(res)
const obj = await res.json()
api.itemMap.value.set(obj.id, obj)
return obj
}
return { create }
}
export function APIRemove<T extends APIObject>(api: ExtendableAPI<T>) {
async function remove(id: ID, requestInit?: RequestInit): Promise<void> {
const res = await fetch(
api.createRequest(api.endpoint(id.toString()), requestInit, { method: 'DELETE' }),
)
await api.maybeRaiseResponse(res)
api.itemMap.value.delete(id)
}
return { remove }
}
export function createUnpaginatedAPIStore<T extends APIObject>(
storeId: string,
endpoint: URLBuilder,
options?: APIStoreOptions<T>,
options?: APIStoreOptions,
) {
return defineStore(storeId, () => {
const { base, ...api } = createExtendableAPI<T>(endpoint, options)
const { api, base } = createExtendableAPI<T>(endpoint, options)
return {
...base,
...APIListUnpaginated(api),
......@@ -308,94 +30,3 @@ export function createUnpaginatedAPIStore<T extends APIObject>(
}
})
}
export function useAPIObject<T extends APIObject>(
store: {
itemMap: Map<ID, T>
retrieve: ReturnType<typeof APIRetrieve<T>>['retrieve']
},
id: Ref<ID | null>,
) {
const obj = computed<T | null>(() => {
return id.value !== null ? store.itemMap.get(id.value) ?? null : null
})
const isLoading = ref(false)
watchEffect(async () => {
if (id.value) {
isLoading.value = true
try {
// Force an API request in case the object is not yet in the store.
await store.retrieve(id.value, undefined, { useCached: true })
} finally {
isLoading.value = false
}
}
})
return { obj, isLoading }
}
export function useServerErrors(error: Ref<Error | undefined>) {
return computed<ErrorMap>(() => {
const _error: Error | undefined = error.value
const result: ErrorMap = { [SERVER_ERRORS_GLOBAL]: [] }
if (_error instanceof APIResponseError) {
const { status } = _error.response
if (status >= 500) {
result[SERVER_ERRORS_GLOBAL].push({ message: '', code: 'server.unknown' })
}
if (status >= 400) {
if (_error.data !== null && typeof _error.data === 'object') {
for (const [field, _errors] of Object.entries(_error.data)) {
const errors = Array.isArray(_errors) ? _errors : [_errors]
result[field] = errors.map((err: string | ErrorDetail) => {
if (typeof err === 'string') {
return { message: err, code: '' }
} else {
return { message: err.message, code: `server.${err.code}` }
}
})
}
}
}
}
return result
})
}
type FieldName = string | typeof SERVER_ERRORS_GLOBAL
type MappedFields<T> = { [K in keyof T]: T[K] }
export function useServerFieldErrors(
error: Ref<Error | undefined>,
...fields: (FieldName | FieldName[])[]
) {
const serverErrors = useServerErrors(error)
const consumedFields = new Set(fields.flat())
const result: ComputedRef<ErrorDetail[]>[] = []
for (const field of fields) {
const fieldNames: FieldName[] = Array.isArray(field) ? field : [field]
const fieldErrors = computed(() => {
const errors: ErrorDetail[] = []
for (const fieldName of fieldNames) {
errors.push(...(serverErrors.value[fieldName] ?? []))
}
return errors
})
result.push(fieldErrors)
}
// contains all errors for which no individual error container was created
const remainingErrors = computed(() => {
const errors: ErrorDetail[] = []
for (const fieldName of Object.keys(serverErrors.value)) {
if (!consumedFields.has(fieldName)) {
errors.push(...serverErrors.value[fieldName])
}
}
return errors
})
result.push(remainingErrors)
return result as MappedFields<ComputedRef<ErrorDetail[]>[]>
}
......@@ -6,52 +6,6 @@
:root {
--primary: theme('colors.gray.800');
--info: theme('colors.aura.purple');
/* TODO: remove these once we include the tailwind base module */
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
header a,
......@@ -147,6 +101,12 @@ thead .fc-day-selected:hover {
color: var(--orange);
}
@layer utilities {
.tw-grid-area-cover {
grid-area: 1 / -1 / 1 / -1;
}
}
@layer components {
.btn-default {
@apply tw-bg-gray-100 tw-border tw-border-solid tw-border-black/5 tw-transition hover:enabled:tw-brightness-95 focus-visible:enabled:tw-brightness-95;
......
......@@ -299,7 +299,7 @@ function createShowSlot(timeslot: TimeSlot): ShowSlot {
const show = shows.value.find(({ id }) => timeslot.showId === id)
const playlistId = timeslot.playlistId ?? show?.defaultPlaylistId
// enqueue a request for the playlist in case it’s not yet in the store
if (playlistId) playlistStore.retrieve(playlistId, undefined, { useCached: true })
if (playlistId) playlistStore.retrieve(playlistId, { useCached: true })
const playlist = playlistId ? playlistStore.itemMap.get(playlistId) : undefined
const start = parseISO(timeslot.start)
const end = parseISO(timeslot.end)
......
<template>
<div
ref="rootEl"
class="tw-relative tw-cursor-text"
class="tw-relative tw-cursor-text group"
@keyup.esc="close"
@keydown.esc.stop.prevent
@click="inputEl?.focus?.()"
......@@ -16,11 +16,7 @@
>
<slot name="pre" />
<label
v-if="label"
:for="inputId"
class="tw-absolute tw-top-2 tw-left-8 tw-text-sm tw-font-bold tw-pointer-events-none"
>
<label v-if="label" :for="inputId" :class="labelClass">
{{ label }}
</label>
......@@ -50,13 +46,14 @@
@keydown.delete="maybeRemoveChoice"
/>
<slot name="selected" :deselect="selectChoice" :is-open="isOpen" />
<slot name="selected" :value="modelValue" :deselect="selectChoice" :is-open="isOpen" />
<kbd
v-if="keyboardShortcut && keyboardShortcutLabel !== undefined && !isTouch"
v-show="!isOpen"
:title="keyboardShortcutLabel"
class="tw-absolute tw-pointer-events-none tw-top-4 tw-right-8 tw-bg-black/20 tw-opacity-60 tw-p-2 tw-text-xs tw-rounded-lg tw-leading-none"
class="tw-absolute tw-pointer-events-none tw-p-2 tw-text-xs tw-leading-none"
:class="keyboardShortcutClass"
>
<template v-for="(token, index) in keyboardShortcutLabel.split(' ')" :key="index">
<span v-if="token !== '+'">{{ token }}</span>
......@@ -80,7 +77,7 @@
drawerClass,
{
'tw-fixed tw-left-2 tw-right-2 tw-z-20 tw-mt-2 tw-bg-white tw-shadow-2xl tw-rounded tw-flex tw-flex-col tw-overflow-hidden': true,
'md:tw-absolute md:tw-top-full md:tw-w-fit md:tw-flex-row': true,
'md:tw-absolute md:tw-w-fit md:tw-flex-row': true,
},
]"
>
......@@ -123,8 +120,31 @@
</div>
</template>
<script lang="ts">
import type { Ref } from 'vue'
export type ComboBoxProps<T> = {
choices: T[]
keyboardShortcut?: Ref<boolean> | undefined
keyboardShortcutLabel?: string
label?: string
noDataLabel?: string
getKey?: (obj: T) => string | number
closeOnSelect?: boolean
inputClass?: unknown
inputContainerClass?: unknown
keyboardShortcutClass?: unknown
labelClass?: unknown
drawerClass?: unknown
// TODO: this fixes errors with arbitrary attributes (like data-*), but
// is just a workaround for https://github.com/vuejs/core/issues/8372
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[attrs: string]: any
}
</script>
<script lang="ts" setup generic="T">
import { computed, nextTick, Ref, ref, useSlots, watch, watchEffect } from 'vue'
import { computed, nextTick, ref, useSlots, watch, watchEffect } from 'vue'
import { onClickOutside, useFocusWithin, useMediaQuery, whenever } from '@vueuse/core'
import { clamp, useId } from '@/util'
......@@ -142,53 +162,43 @@ defineSlots<{
activeIndex: number
},
): unknown
selected(props: { deselect: (choice: T) => void; isOpen: boolean }): unknown
selected(props: {
value: null | T | T[]
deselect: (choice: null | T) => void
isOpen: boolean
}): unknown
filter?(props: { resultsId: string }): any
noData?(props: Record<string, never>): any
pre?(props: Record<string, never>): any
}>()
const props = withDefaults(
defineProps<{
modelValue: T | T[]
choices: T[]
keyboardShortcut?: Ref<boolean> | undefined
keyboardShortcutLabel?: string
label?: string
noDataLabel?: string
getKey?: (obj: T) => string | number
closeOnSelect?: boolean
inputClass?: unknown
inputContainerClass?: unknown
drawerClass?: unknown
}>(),
{
keyboardShortcut: undefined,
keyboardShortcutLabel: '',
label: '',
noDataLabel: '',
getKey: (obj: T) => {
if (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
(typeof obj.id === 'string' || typeof obj.id === 'number')
) {
return obj.id
} else if (typeof obj === 'string' || typeof obj === 'number') {
return obj
} else {
throw new TypeError('You need to define a custom getKey function for your ComboBox object')
}
},
closeOnSelect: true,
inputClass: undefined,
inputContainerClass: undefined,
drawerClass: undefined,
const modelValue = defineModel<null | T | T[]>({ required: true })
const props = withDefaults(defineProps<ComboBoxProps<T>>(), {
keyboardShortcut: undefined,
keyboardShortcutLabel: '',
label: '',
noDataLabel: '',
getKey: (obj: T) => {
if (
typeof obj === 'object' &&
obj !== null &&
'id' in obj &&
(typeof obj.id === 'string' || typeof obj.id === 'number')
) {
return obj.id
} else if (typeof obj === 'string' || typeof obj === 'number') {
return obj
} else {
throw new TypeError('You need to define a custom getKey function for your ComboBox object')
}
},
)
closeOnSelect: true,
inputClass: undefined,
inputContainerClass: undefined,
keyboardShortcutClass: undefined,
drawerClass: undefined,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: T | T[]): void
(e: 'search', value: string): void
(e: 'open'): void
(e: 'close'): void
......@@ -244,12 +254,12 @@ function ensureOpen(event: Event) {
}
}
function selectChoice(choice: T) {
if (!Array.isArray(props.modelValue)) {
emit('update:modelValue', choice)
function selectChoice(choice: T | null) {
if (!Array.isArray(modelValue.value) || choice === null) {
modelValue.value = choice
} else {
const objIdentity = props.getKey(choice)
const dataCopy = Array.from(props.modelValue)
const dataCopy = Array.from(modelValue.value)
let isHandled = false
for (const [index, item] of dataCopy.entries()) {
......@@ -264,19 +274,21 @@ function selectChoice(choice: T) {
inputEl?.value?.focus?.()
}
emit('update:modelValue', dataCopy)
modelValue.value = dataCopy
}
if (props.closeOnSelect) {
nextTick(close)
} else if (choice !== null) {
query.value = ''
}
}
function maybeRemoveChoice() {
if (query.value === '' && Array.isArray(props.modelValue)) {
const newValue = Array.from(props.modelValue)
if (query.value === '' && Array.isArray(modelValue.value)) {
const newValue = Array.from(modelValue.value)
newValue.splice(newValue.length - 1, 1)
emit('update:modelValue', newValue)
modelValue.value = newValue
}
}
......
<template>
<ComboBox
v-model="modelValue"
v-bind="props"
:choices="filteredChoices"
:close-on-select="props.closeOnSelect ?? !Array.isArray(modelValue)"
input-container-class="tw-flex tw-flex-wrap tw-p-2 tw-gap-2 tw-items-baseline tw-w-full form-control tw-h-auto tw-min-h-[46px]"
input-class="tw-border-none tw-px-1 tw-w-[100px] focus:tw-shadow-none focus:tw-outline-none focus:tw-ring-0"
@search="searchQuery = $event"
>
<template #default="{ choice, ...attributes }">
<slot :choice="choice" v-bind="attributes">
<li v-bind="attributes">
{{ choice.name }}
</li>
</slot>
</template>
<template #selected="{ value, deselect, isOpen }">
<slot name="selected" :value="value" :deselect="deselect" :is-open="isOpen">
<template v-if="Array.isArray(value)">
<Tag
v-for="(item, index) in value"
:key="index"
:label="item.name"
removable
@remove="deselect(item)"
/>
</template>
<template v-else>
<p v-if="value">{{ value.name }}</p>
</template>
</slot>
</template>
</ComboBox>
</template>
<script lang="ts">
import type { ComboBoxProps } from '@/components/ComboBox.vue'
export type ComboBoxSimpleProps<T> = Omit<ComboBoxProps<T>, 'choices'> & {
choices?: T[]
searchProvider?: (query: string, signal: AbortSignal) => Promise<T[]>
}
</script>
<script setup lang="ts" generic="T extends { id: ID, name?: string }">
import { ID } from '@rokoli/bnb/drf'
import { computedAsync } from '@vueuse/core'
import { computed, ref, toValue } from 'vue'
import { computedDebounced, matchesSearch } from '@/util'
import Tag from './generic/Tag.vue'
import ComboBox from './ComboBox.vue'
defineOptions({
compatConfig: { MODE: 3 },
})
defineSlots<{
default(
props: Record<string, unknown> & {
id: string
choice: T
index: number
activeIndex: number
},
): unknown
selected(props: {
value: null | T | T[]
deselect: (choice: null | T) => void
isOpen: boolean
}): unknown
}>()
const modelValue = defineModel<null | T | T[]>({ required: true })
const props = defineProps<ComboBoxSimpleProps<T>>()
const searchQuery = ref('')
const debouncedSearchQuery = computedDebounced(searchQuery, (t) => (t.trim() ? 0.3 : 0))
const selectedIds = computed(() => {
if (Array.isArray(modelValue.value)) {
return modelValue.value.map((item) => item.id)
} else if (modelValue.value !== null) {
return [modelValue.value.id]
} else {
return []
}
})
let abortController: AbortController | null = null
const searchedChoices = computedAsync(
async () => {
if (props.choices) {
const query = toValue(searchQuery)
return props.choices.filter(({ name }) => matchesSearch(name ?? '', query))
} else if (props.searchProvider) {
const query = toValue(debouncedSearchQuery)
if (abortController) abortController.abort()
abortController = new AbortController()
try {
return await props.searchProvider(query, abortController.signal)
} finally {
abortController = null
}
} else {
return []
}
},
props.choices ?? [],
{ lazy: false },
)
const filteredChoices = computed(() =>
searchedChoices.value.filter((choice) => !selectedIds.value.includes(choice.id)),
)
</script>