From ba9f75118f98e0901498291cb35235095e0a51a1 Mon Sep 17 00:00:00 2001 From: Umar Hansa Date: Sat, 18 Jul 2020 23:16:43 +0100 Subject: [PATCH] WIP choices --- Makefile | 4 +- package-lock.json | 39 +- package.json | 5 +- readme.md | 2 - src/client/css/base/base.scss | 38 -- src/client/css/base/mvp.scss | 448 ++++++++++++++++++ src/client/css/main.scss | 2 + src/client/css/pages/playlists.scss | 2 + .../20200718195038_make_playlists.js | 12 + .../migrations/20200718212335_make_choices.js | 21 + src/server/db/queries/choices-queries.js | 19 + src/server/db/queries/playlists-queries.js | 33 ++ src/server/routes/index.js | 46 ++ src/server/views/partials/nav.html | 2 +- .../partials/playlists/playlist-form.html | 17 + .../views/partials/playlists/playlists.html | 32 ++ src/server/views/playlists.html | 11 + 17 files changed, 672 insertions(+), 61 deletions(-) create mode 100644 src/client/css/base/mvp.scss create mode 100644 src/client/css/pages/playlists.scss create mode 100644 src/server/db/migrations/20200718195038_make_playlists.js create mode 100644 src/server/db/migrations/20200718212335_make_choices.js create mode 100644 src/server/db/queries/choices-queries.js create mode 100644 src/server/db/queries/playlists-queries.js create mode 100644 src/server/views/partials/playlists/playlist-form.html create mode 100644 src/server/views/partials/playlists/playlists.html create mode 100644 src/server/views/playlists.html diff --git a/Makefile b/Makefile index 2c0b92c..669674a 100644 --- a/Makefile +++ b/Makefile @@ -5,6 +5,8 @@ ENV ?= development install: npm install + echo '' > node_modules/interpret/mjs-stub.js + echo 'Doing a weird hack to address https://github.com/knex/knex/issues/3882' update-deps: ncu -u @@ -22,7 +24,7 @@ reset: rm -rf ~/Downloads/video-everyday/segments/* rm -rf ~/Downloads/video-everyday/thumbnails/* rm db-development-video-everyday.sqlite - npm run migrate-db-dev + make migrate clean-dist: rm -rf dist diff --git a/package-lock.json b/package-lock.json index 3931c96..14ac2f3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1742,9 +1742,9 @@ "dev": true }, "colorette": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.1.0.tgz", - "integrity": "sha512-6S062WDQUXi6hOfkO/sBPVwE5ASXY4G2+b4atvhJfSsuUUhIaUKlkjLe9692Ipyt5/a+IPF5aVTu3V5gvXq5cg==" + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.2.1.tgz", + "integrity": "sha512-puCDz0CzydiSYOrnXpz/PKd69zRrribezjtE9yd4zvytoRc8+RY/KJPvtPFKZS3E3wP6neGyMe0vOTlHO5L3Pw==" }, "colors": { "version": "1.4.0", @@ -5675,25 +5675,25 @@ "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==" }, "knex": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/knex/-/knex-0.21.1.tgz", - "integrity": "sha512-uWszXC2DPaLn/YznGT9wFTWUG9+kqbL4DMz+hCH789GLcLuYzq8werHPDKBJxtKvxrW/S1XIXgrTWdMypiVvsw==", + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/knex/-/knex-0.21.2.tgz", + "integrity": "sha512-hNp9f3yXCHtMrhV2pVsuCNYmPlgXhyqviMQGLBd9zdF03ZqCO9MPng0oYhNMgIs+vDr55VC6tjEbF1OQ1La7Kg==", "requires": { - "colorette": "1.1.0", + "colorette": "1.2.1", "commander": "^5.1.0", "debug": "4.1.1", "esm": "^3.2.25", "getopts": "2.2.5", "inherits": "~2.0.4", - "interpret": "^2.0.0", + "interpret": "^2.2.0", "liftoff": "3.1.0", - "lodash": "^4.17.15", + "lodash": "^4.17.19", "mkdirp": "^1.0.4", - "pg-connection-string": "2.2.0", + "pg-connection-string": "2.3.0", "tarn": "^3.0.0", "tildify": "2.0.0", "uuid": "^7.0.3", - "v8flags": "^3.1.3" + "v8flags": "^3.2.0" }, "dependencies": { "inherits": { @@ -7203,9 +7203,9 @@ "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" }, "pg-connection-string": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.2.0.tgz", - "integrity": "sha512-xB/+wxcpFipUZOQcSzcgkjcNOosGhEoPSjz06jC89lv1dj7mc9bZv6wLVy8M2fVjP0a/xN0N988YDq1L0FhK3A==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.3.0.tgz", + "integrity": "sha512-ukMTJXLI7/hZIwTW7hGMZJ0Lj0S2XQBCJ4Shv4y1zgQ/vqVea+FLhzywvPj0ujSuofu+yA4MYHGZPTsgjBgJ+w==" }, "picomatch": { "version": "2.2.2", @@ -8001,9 +8001,9 @@ } }, "rollup": { - "version": "2.21.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.21.0.tgz", - "integrity": "sha512-BEGgy+wSzux7Ycq58pRiWEOBZaXRXTuvzl1gsm7gqmsAHxkWf9nyA5V2LN9fGSHhhDQd0/C13iRzSh4bbIpWZQ==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.22.0.tgz", + "integrity": "sha512-TNuj5gQTwMu3hcM65HcBTx62N04/7v+4LRH6HOWe497hG0ic5RRfe+Vr88km8XfeApd/AIIQtVfRVNHk92Knmg==", "dev": true, "requires": { "fsevents": "~2.1.2" @@ -8530,6 +8530,11 @@ } } }, + "slugify": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.4.4.tgz", + "integrity": "sha512-N2+9NJ8JzfRMh6PQLrBeDEnVDQZSytE/W4BTC4fNNPmO90Uu58uNwSlIJSs+lmPgWsaAF79WLhVPe5tuy7spjw==" + }, "snapdragon": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", diff --git a/package.json b/package.json index ec5eafa..8cc14a4 100644 --- a/package.json +++ b/package.json @@ -17,13 +17,14 @@ "express-session": "^1.17.1", "fluent-ffmpeg": "^2.1.2", "forcedomain": "^2.0.0", - "knex": "^0.21.1", + "knex": "^0.21.2", "moment": "^2.27.0", "nunjucks": "^3.2.1", "passport": "^0.4.1", "rimraf": "^3.0.2", "sharp": "^0.25.4", "signale": "^1.4.0", + "slugify": "^1.4.4", "sqlite3": "^5.0.0", "subtitle": "^2.0.3" }, @@ -39,7 +40,7 @@ "gulp-rev-rewrite": "^3.0.3", "gulp-sass": "^4.1.0", "gulp-sourcemaps": "^2.6.5", - "rollup": "^2.21.0", + "rollup": "^2.22.0", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-node-resolve": "^5.2.0", "rollup-plugin-terser": "^6.1.0", diff --git a/readme.md b/readme.md index 1d54576..57f7199 100644 --- a/readme.md +++ b/readme.md @@ -49,8 +49,6 @@ The web interface allows you to (optionally) select which media to use in your f - Persist choices to DB - Playlist functionality (so you can make multiple videos) - + Needs 1x choices table - * fields: choice_id, date bucket, media item ID, + needs a playlists table * CRUD support for playlist names * Creation of a new playlist entry needs new records in the choices table (i.e. the defaults) diff --git a/src/client/css/base/base.scss b/src/client/css/base/base.scss index afe8c19..a25b44d 100644 --- a/src/client/css/base/base.scss +++ b/src/client/css/base/base.scss @@ -10,19 +10,6 @@ * { box-sizing: border-box } -body { - font-family: 'Open Sans', sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - margin: 0; - padding: 0; - padding-bottom: 10px; - background: white; -} - -a { - color: #00B7FF; -} ul, ol { padding-left: 0; @@ -59,28 +46,3 @@ ul, ol { .container { @include container; } - -input[type="submit"], -button[type="submit"] { - font-size: 15px; - padding: 10px; -} - -.form-group { - margin: 25px 0; - display: flex; - flex-direction: column; - - label { - margin-bottom: 10px; - } - - - button[type="submit"], - input[type="password"], - input[type="email"] { - padding: 10px; - border: 1px solid #ddd; - font-size: 15px; - } -} diff --git a/src/client/css/base/mvp.scss b/src/client/css/base/mvp.scss new file mode 100644 index 0000000..f7e4e05 --- /dev/null +++ b/src/client/css/base/mvp.scss @@ -0,0 +1,448 @@ +/* MVP.css v1.6.2 - https://github.com/andybrewer/mvp */ + +:root { + --border-radius: 5px; + --box-shadow: 2px 2px 10px; + --color: #118bee; + --color-accent: #118bee15; + --color-bg: #fff; + --color-bg-secondary: #e9e9e9; + --color-secondary: #920de9; + --color-secondary-accent: #920de90b; + --color-shadow: #f4f4f4; + --color-text: #000; + --color-text-secondary: #999; + --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + --hover-brightness: 1.2; + --justify-important: center; + --justify-normal: left; + --line-height: 1.5; + --width-card: 285px; + --width-card-medium: 460px; + --width-card-wide: 800px; + --width-content: 1080px; +} + +/* +@media (prefers-color-scheme: dark) { + :root { + --color: #0097fc; + --color-accent: #0097fc4f; + --color-bg: #333; + --color-bg-secondary: #555; + --color-secondary: #e20de9; + --color-secondary-accent: #e20de94f; + --color-shadow: #bbbbbb20; + --color-text: #f7f7f7; + --color-text-secondary: #aaa; + } +} +*/ + +/* Layout */ +article aside { + background: var(--color-secondary-accent); + border-left: 4px solid var(--color-secondary); + padding: 0.01rem 0.8rem; +} + +body { + background: var(--color-bg); + color: var(--color-text); + font-family: var(--font-family); + line-height: var(--line-height); + margin: 0; + overflow-x: hidden; + padding: 1rem 0; +} + +footer, +header, +main { + margin: 0 auto; + max-width: var(--width-content); + padding: 2rem 1rem; +} + +hr { + background-color: var(--color-bg-secondary); + border: none; + height: 1px; + margin: 4rem 0; +} + +section { + display: flex; + flex-wrap: wrap; + justify-content: var(--justify-important); +} + +section aside { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + margin: 1rem; + padding: 1.25rem; + width: var(--width-card); +} + +section aside:hover { + box-shadow: var(--box-shadow) var(--color-bg-secondary); +} + +section aside img { + max-width: 100%; +} + +[hidden] { + display: none; +} + +/* Headers */ +article header, +div header, +main header { + padding-top: 0; +} + +header { + text-align: var(--justify-important); +} + +header a b, +header a em, +header a i, +header a strong { + margin-left: 0.5rem; + margin-right: 0.5rem; +} + +header nav img { + margin: 1rem 0; +} + +section header { + padding-top: 0; + width: 100%; +} + +/* Nav */ +nav { + align-items: center; + display: flex; + font-weight: bold; + justify-content: space-between; + margin-bottom: 7rem; +} + +nav ul { + list-style: none; + padding: 0; +} + +nav ul li { + display: inline-block; + margin: 0 0.5rem; + position: relative; + text-align: left; +} + +/* Nav Dropdown */ +nav ul li:hover ul { + display: block; +} + +nav ul li ul { + background: var(--color-bg); + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + display: none; + height: auto; + left: -2px; + padding: .5rem 1rem; + position: absolute; + top: 1.7rem; + white-space: nowrap; + width: auto; +} + +nav ul li ul li, +nav ul li ul li a { + display: block; +} + +/* Typography */ +code, +samp { + background-color: var(--color-accent); + border-radius: var(--border-radius); + color: var(--color-text); + display: inline-block; + margin: 0 0.1rem; + padding: 0 0.5rem; +} + +details { + margin: 1.3rem 0; +} + +details summary { + font-weight: bold; + cursor: pointer; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + line-height: var(--line-height); +} + +mark { + padding: 0.1rem; +} + +ol li, +ul li { + padding: 0.2rem 0; +} + +p { + margin: 0.75rem 0; + padding: 0; +} + +pre { + margin: 1rem 0; + max-width: var(--width-card-wide); + padding: 1rem 0; +} + +pre code, +pre samp { + display: block; + max-width: var(--width-card-wide); + padding: 0.5rem 2rem; + white-space: pre-wrap; +} + +small { + color: var(--color-text-secondary); +} + +sup { + background-color: var(--color-secondary); + border-radius: var(--border-radius); + color: var(--color-bg); + font-size: xx-small; + font-weight: bold; + margin: 0.2rem; + padding: 0.2rem 0.3rem; + position: relative; + top: -2px; +} + +/* Links */ +a { + color: var(--color-secondary); + display: inline-block; + font-weight: bold; + text-decoration: none; +} + +a:hover { + filter: brightness(var(--hover-brightness)); + text-decoration: underline; +} + +a b, +a em, +a i, +a strong, +button { + border-radius: var(--border-radius); + display: inline-block; + font-size: medium; + font-weight: bold; + line-height: var(--line-height); + margin: 0.5rem 0; + padding: 1rem 2rem; +} + +button { + font-family: var(--font-family); +} + +button:hover { + cursor: pointer; + filter: brightness(var(--hover-brightness)); +} + +a b, +a strong, +button { + background-color: var(--color); + border: 2px solid var(--color); + color: var(--color-bg); +} + +a em, +a i { + border: 2px solid var(--color); + border-radius: var(--border-radius); + color: var(--color); + display: inline-block; + padding: 1rem 2rem; +} + +/* Images */ +figure { + margin: 0; + padding: 0; +} + +figure img { + max-width: 100%; +} + +figure figcaption { + color: var(--color-text-secondary); +} + +/* Forms */ + +button:disabled, +input:disabled { + background: var(--color-bg-secondary); + border-color: var(--color-bg-secondary); + color: var(--color-text-secondary); + cursor: not-allowed; +} + +button[disabled]:hover { + filter: none; +} + +form { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + box-shadow: var(--box-shadow) var(--color-shadow); + display: block; + max-width: var(--width-card-wide); + min-width: var(--width-card); + padding: 1.5rem; + text-align: var(--justify-normal); +} + +form header { + margin: 1.5rem 0; + padding: 1.5rem 0; +} + +input, +label, +select, +textarea { + display: block; + font-size: inherit; + max-width: var(--width-card-wide); +} + +input[type="checkbox"], +input[type="radio"] { + display: inline-block; +} + +input[type="checkbox"]+label, +input[type="radio"]+label { + display: inline-block; + font-weight: normal; + position: relative; + top: 1px; +} + +input, +select, +textarea { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + margin-bottom: 1rem; + padding: 0.4rem 0.8rem; +} + +input[readonly], +textarea[readonly] { + background-color: var(--color-bg-secondary); +} + +label { + font-weight: bold; + margin-bottom: 0.2rem; +} + +/* Tables */ +table { + border: 1px solid var(--color-bg-secondary); + border-radius: var(--border-radius); + border-spacing: 0; + display: inline-block; + max-width: 100%; + overflow-x: auto; + padding: 0; + white-space: nowrap; +} + +table td, +table th, +table tr { + padding: 0.4rem 0.8rem; + text-align: var(--justify-important); +} + +table thead { + background-color: var(--color); + border-collapse: collapse; + border-radius: var(--border-radius); + color: var(--color-bg); + margin: 0; + padding: 0; +} + +table thead th:first-child { + border-top-left-radius: var(--border-radius); +} + +table thead th:last-child { + border-top-right-radius: var(--border-radius); +} + +table thead th:first-child, +table tr td:first-child { + text-align: var(--justify-normal); +} + +table tr:nth-child(even) { + background-color: var(--color-accent); +} + +/* Quotes */ +blockquote { + display: block; + font-size: x-large; + line-height: var(--line-height); + margin: 1rem auto; + max-width: var(--width-card-medium); + padding: 1.5rem 1rem; + text-align: var(--justify-important); +} + +blockquote footer { + color: var(--color-text-secondary); + display: block; + font-size: small; + line-height: var(--line-height); + padding: 1.5rem 0; +} \ No newline at end of file diff --git a/src/client/css/main.scss b/src/client/css/main.scss index b7f8853..8859ef3 100755 --- a/src/client/css/main.scss +++ b/src/client/css/main.scss @@ -4,8 +4,10 @@ $container-width: 1280px; $primary-blue: #2879ff; @import 'base/base'; +@import 'base/mvp'; @import 'components/footer'; @import 'components/nav'; @import 'components/button'; @import 'pages/home'; +@import 'pages/playlists'; diff --git a/src/client/css/pages/playlists.scss b/src/client/css/pages/playlists.scss new file mode 100644 index 0000000..23c5290 --- /dev/null +++ b/src/client/css/pages/playlists.scss @@ -0,0 +1,2 @@ +.playlists-list { +} \ No newline at end of file diff --git a/src/server/db/migrations/20200718195038_make_playlists.js b/src/server/db/migrations/20200718195038_make_playlists.js new file mode 100644 index 0000000..3e83c60 --- /dev/null +++ b/src/server/db/migrations/20200718195038_make_playlists.js @@ -0,0 +1,12 @@ +exports.up = function (knex) { + return knex.schema.createTable('playlists', table => { + table.increments(); + table.timestamps(undefined, true); + table.string('name').unique().notNullable(); + table.string('slug').unique().notNullable(); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTable('playlists'); +}; diff --git a/src/server/db/migrations/20200718212335_make_choices.js b/src/server/db/migrations/20200718212335_make_choices.js new file mode 100644 index 0000000..13cdc5e --- /dev/null +++ b/src/server/db/migrations/20200718212335_make_choices.js @@ -0,0 +1,21 @@ +exports.up = function (knex) { + return knex.schema.createTable('choices', table => { + table.increments(); + + table.integer('mediaMetadata_id') + .references('id') + .inTable('mediaMetadata') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + + table.integer('playlists_id') + .references('id') + .inTable('playlists') + .onUpdate('CASCADE') + .onDelete('CASCADE'); + }); +}; + +exports.down = function (knex) { + return knex.schema.dropTable('choices'); +}; diff --git a/src/server/db/queries/choices-queries.js b/src/server/db/queries/choices-queries.js new file mode 100644 index 0000000..9850cd4 --- /dev/null +++ b/src/server/db/queries/choices-queries.js @@ -0,0 +1,19 @@ +import knex from '../connection.js'; + +const choicesTableName = 'choices'; + +async function test() { + return knex(choicesTableName).select( + `${choicesTableName}.*`, + 'playlists.slug' + ) + .join('playlists', {[ + `${choicesTableName}.playlists_id`]: 'playlists.id' + }); +} + +const exports = { + +}; + +export default exports; diff --git a/src/server/db/queries/playlists-queries.js b/src/server/db/queries/playlists-queries.js new file mode 100644 index 0000000..6d0b3e7 --- /dev/null +++ b/src/server/db/queries/playlists-queries.js @@ -0,0 +1,33 @@ +import knex from '../connection.js'; +import slugify from 'slugify'; + +const playlistsTableName = 'playlists'; + +async function getAllPlaylists() { + return knex.select('*').from(playlistsTableName); +} + +async function insert(playlistName) { + return knex(playlistsTableName).insert({ + name: playlistName, + slug: slugify(playlistName) + }); +} + +async function update({newPlaylistName, oldSlug}) { + return knex(playlistsTableName) + .update({ + name: newPlaylistName, + slug: slugify(newPlaylistName), + updated_at: new Date().toISOString() + }) + .where('slug', oldSlug); +} + +const exports = { + insert, + getAllPlaylists, + update +}; + +export default exports; diff --git a/src/server/routes/index.js b/src/server/routes/index.js index 366d926..b7a73d2 100755 --- a/src/server/routes/index.js +++ b/src/server/routes/index.js @@ -11,6 +11,7 @@ import moment from 'moment'; import * as Subtitle from 'subtitle'; import mediaMetadataQueries from '../db/queries/media-metadata-queries.js'; +import playlistsQueries from '../db/queries/playlists-queries.js'; import {getMediaType} from '../lib/is-valid-media-type.js'; dotenv.config(); @@ -19,6 +20,51 @@ const router = express.Router(); // eslint-disable-line new-cap const webServerMediaPath = config.get('web-server-media-path'); +router.get('/playlists', async (request, response) => { + const playlists = await playlistsQueries.getAllPlaylists(); + + const renderObject = { + playlists + }; + + response.render('playlists', renderObject); +}); + +router.post('/playlists', async (request, response) => { + const newPlaylistName = request.body['playlist-name']; + + if (!newPlaylistName) { + throw new Error('No playlist name provided!'); + } + + await playlistsQueries.insert(newPlaylistName); + + // Add to choices table! + + request.flash('messages', { + status: 'success', + value: 'Playlist created' + }); + + response.redirect('/playlists'); +}); + +router.post('/playlist/:slug', async (request, response) => { + const newPlaylistName = request.body['playlist-name']; + const oldSlug = request.params.slug; + + if (!newPlaylistName) { + throw new Error('No playlist name provided!'); + } + + await playlistsQueries.update({ + newPlaylistName, + oldSlug + }); + + response.redirect('/playlists'); +}); + router.post('/consolidate-media', async (request, response) => { // Still need to apply sorting before copying media over const videoSegmentFolder = config.get('video-segment-folder'); diff --git a/src/server/views/partials/nav.html b/src/server/views/partials/nav.html index b5949cf..4c84d80 100755 --- a/src/server/views/partials/nav.html +++ b/src/server/views/partials/nav.html @@ -4,7 +4,7 @@

{{config.productName}}

diff --git a/src/server/views/partials/playlists/playlist-form.html b/src/server/views/partials/playlists/playlist-form.html new file mode 100644 index 0000000..e6f2009 --- /dev/null +++ b/src/server/views/partials/playlists/playlist-form.html @@ -0,0 +1,17 @@ +
+ + + +
+ + \ No newline at end of file diff --git a/src/server/views/partials/playlists/playlists.html b/src/server/views/partials/playlists/playlists.html new file mode 100644 index 0000000..b551701 --- /dev/null +++ b/src/server/views/partials/playlists/playlists.html @@ -0,0 +1,32 @@ +
+

Your Playlists ({{playlists.length}})

+ +
+ {% include './playlist-form.html' %} +
+ + +
diff --git a/src/server/views/playlists.html b/src/server/views/playlists.html new file mode 100644 index 0000000..6a7d73d --- /dev/null +++ b/src/server/views/playlists.html @@ -0,0 +1,11 @@ +{% extends '_base.html' %} + +{% block title %}{% endblock %} + +{% block content %} + +
+ {% include "./partials/playlists/playlists.html" %} +
+ +{% endblock %}