Initial commit

This commit is contained in:
Kumi 2022-05-05 17:40:57 +02:00
commit 467e8001d8
Signed by: kumi
GPG key ID: 5D1CE6AF1805ECA2
48 changed files with 2963 additions and 0 deletions

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
venv/
*.pyc
__pycache__/
settings.ini
db.sqlite3

23
README.md Normal file
View file

@ -0,0 +1,23 @@
# Steel Donut Collection
Steel Donut Collection is a simple web app that will take videos downloaded using youtube-dl/yt-dlp from an S3 bucket and make them watchable. This was created as a way to have our own Steel Donut Collective repository at home, just in case their YouTube channel ever goes down after they disbanded. (insert sad emoji here)
It will also work with other YouTube videos, I'm told, but I didn't test that.
## Downloading videos
I used the following command to download the videos from the SDC channel:
```
yt-dlp --write-info-json --write-description --write-annotations --all-subs --write-thumbnail -f best --format mp4 https://www.youtube.com/c/SteelDonutCollective/videos
```
I just uploaded the resulting files to a Minio (S3 compatible) bucket.
## Setting up application
The easiest way to get started is copying the provided "settings.dist.ini" to "settings.ini" and setting the required values therein. Then run `./manage.py migrate` to set up the sqlite3 database and you are good to go.
## Importing videos
Once you have the database set up, you can run `./manage.py importvideos` to automatically import videos from the S3 storage.

0
backend/__init__.py Normal file
View file

7
backend/admin.py Normal file
View file

@ -0,0 +1,7 @@
from django.contrib import admin
from .models import Video, Playlist
admin.site.register(Video)
admin.site.register(Playlist)

6
backend/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class BackendConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'backend'

View file

@ -0,0 +1,48 @@
from django.core.management.base import BaseCommand
from django.conf import settings
from datetime import timedelta, datetime
from json import loads
from backend.s3 import build_s3_from_settings
from backend.models import Video
class Command(BaseCommand):
help = 'Imports all videos from storage'
def handle(self, *args, **kwargs):
s3 = build_s3_from_settings()
if not s3.bucket_exists(settings.S3_BUCKET):
s3.make_bucket(settings.S3_BUCKET)
with open(settings.BASE_DIR / "sdc.json") as policyfile:
policy = policyfile.read()
s3.set_bucket_policy(settings.S3_BUCKET, policy)
objects = list(s3.list_objects(settings.S3_BUCKET))
names = [obj.object_name for obj in objects]
for obj in objects:
if obj.object_name.endswith(".info.json"):
base = obj.object_name.rsplit(".info.json")[0]
if (videoname := f"{base}.mp4") in names:
vjson = s3.get_object(
settings.S3_BUCKET, obj.object_name).read().decode()
vdata = loads(vjson)
try:
vobj = Video.objects.get(id=vdata["id"])
except Video.DoesNotExist:
vobj = Video()
vobj.title = vdata["title"]
vobj.published = datetime.strptime(vdata["upload_date"], "%Y%m%d")
vobj.description = vdata["description"]
vobj.path = videoname
vobj.thumbnail_path = f"{base}.webp"
vobj.original_json = vjson
vobj.save()

View file

@ -0,0 +1,32 @@
# Generated by Django 4.0.4 on 2022-05-05 09:48
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Video',
fields=[
('id', models.CharField(max_length=11, primary_key=True, serialize=False)),
('title', models.CharField(max_length=100)),
('description', models.TextField(blank=True, null=True)),
('path', models.CharField(max_length=1024)),
('original_json', models.JSONField(blank=True, null=True)),
],
),
migrations.CreateModel(
name='Playlist',
fields=[
('id', models.CharField(max_length=34, primary_key=True, serialize=False)),
('title', models.CharField(max_length=100)),
('videos', models.ManyToManyField(to='backend.video')),
],
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.0.4 on 2022-05-05 10:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backend', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='video',
name='thumbnail_path',
field=models.CharField(default='', max_length=1024),
preserve_default=False,
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 4.0.4 on 2022-05-05 11:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backend', '0002_video_thumbnail_path'),
]
operations = [
migrations.AddField(
model_name='video',
name='published',
field=models.DateTimeField(default="1970-01-01 00:00:00"),
preserve_default=False,
),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 4.0.4 on 2022-05-05 14:12
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('backend', '0003_video_published'),
]
operations = [
migrations.RemoveField(
model_name='playlist',
name='videos',
),
migrations.CreateModel(
name='PlaylistVideo',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('order', models.PositiveIntegerField(default=0)),
('playlist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='backend.playlist')),
('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='backend.video')),
],
),
]

View file

@ -0,0 +1,21 @@
# Generated by Django 4.0.4 on 2022-05-05 15:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('backend', '0004_remove_playlist_videos_playlistvideo'),
]
operations = [
migrations.AddField(
model_name='playlist',
name='videos',
field=models.ManyToManyField(to='backend.video'),
),
migrations.DeleteModel(
name='PlaylistVideo',
),
]

View file

44
backend/models.py Normal file
View file

@ -0,0 +1,44 @@
from django.db import models
from django.urls import reverse
from datetime import timedelta
from json import loads
from .s3 import build_presigned_url
class Video(models.Model):
id = models.CharField(max_length=11, primary_key=True)
title = models.CharField(max_length=100)
published = models.DateTimeField()
description = models.TextField(blank=True, null=True)
path = models.CharField(max_length=1024)
thumbnail_path = models.CharField(max_length=1024)
original_json = models.JSONField(blank=True, null=True)
def get_video_url(self):
return build_presigned_url(self.path)
def get_thumbnail_url(self):
return build_presigned_url(self.thumbnail_path, timedelta(minutes=5))
def get_absolute_url(self):
return reverse("video", kwargs={"pk": self.id})
@property
def tags(self):
return loads(self.original_json)["tags"]
def __str__(self):
return self.title
class Playlist(models.Model):
id = models.CharField(max_length=34, primary_key=True)
title = models.CharField(max_length=100)
videos = models.ManyToManyField(Video)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("playlist", kwargs={"pk": self.id})

18
backend/s3.py Normal file
View file

@ -0,0 +1,18 @@
from minio import Minio
from minio.error import S3Error
from django.conf import settings
from datetime import timedelta
def build_s3_from_settings():
return Minio(
endpoint = settings.S3_ENDPOINT,
access_key= settings.S3_ACCESS_KEY,
secret_key= settings.S3_SECRET_KEY
)
def build_presigned_url(path, validity=timedelta(hours=12)):
s3 = build_s3_from_settings()
return s3.get_presigned_url("GET", settings.S3_BUCKET, path)

3
backend/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

3
backend/views.py Normal file
View file

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

0
frontend/__init__.py Normal file
View file

3
frontend/admin.py Normal file
View file

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
frontend/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class FrontendConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'frontend'

View file

3
frontend/models.py Normal file
View file

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View file

View file

@ -0,0 +1,14 @@
from random import choices
from django import template
from backend.models import Video
register = template.Library()
@register.simple_tag
def random_videos(count=4):
ids = Video.objects.values_list("id", flat=True)
for vid in choices(ids, k=count):
yield Video.objects.get(id=vid)

3
frontend/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
frontend/urls.py Normal file
View file

@ -0,0 +1,10 @@
from django.urls import path
from .views import PlaylistView, VideoView
urlpatterns = [
path('', PlaylistView.as_view(), name="home"),
path('video/<slug:pk>/', VideoView.as_view(), name="video"),
path('playlist/<slug:pk>/', PlaylistView.as_view(), name="playlist"),
]

44
frontend/views.py Normal file
View file

@ -0,0 +1,44 @@
from django.views.generic import ListView, DetailView
from django.shortcuts import get_object_or_404
from backend.models import Video, Playlist
class PlaylistView(ListView):
template_name = "frontend/playlist.html"
model = Video
paginate_by = 30
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = "All Videos"
context["playlists"] = Playlist.objects.all()
return context
def get_playlist(self):
if "pk" in self.kwargs.keys():
return get_object_or_404(Playlist, id=self.kwargs["pk"])
def get_queryset(self):
if playlist := self.get_playlist():
queryset = playlist.videos.all()
else:
queryset = Video.objects.all()
queryset = queryset.order_by(self.get_ordering())
return queryset
def get_ordering(self):
return self.request.GET.get('sort', '-published')
class VideoView(DetailView):
template_name = "frontend/video.html"
model = Video
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["title"] = self.object.title
return context

22
manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'steeldonutcollection.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
django
minio
django-autosecretkey

14
sdc.json Normal file
View file

@ -0,0 +1,14 @@
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:*"
],
"Resource": [
"arn:aws:s3:::sdc/*"
]
}
]
}

5
settings.dist.ini Normal file
View file

@ -0,0 +1,5 @@
[S3]
Endpoint = your.minio
AccessKey = your_access_key
SecretKey = supposed_to_be_secret_and_secure!
Bucket = steeldonutbucket

10
static/bootstrap/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

6
static/bootstrap/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

5
static/css/pikaday.min.css vendored Normal file
View file

@ -0,0 +1,5 @@
@charset "UTF-8";/*!
* Pikaday
* Copyright © 2014 David Bushell | BSD & MIT license | http://dbushell.com/
*/.pika-single{z-index:9999;display:block;position:relative;color:#333;background:#fff;border:1px solid #ccc;border-bottom-color:#bbb;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}.pika-single:after,.pika-single:before{content:" ";display:table}.pika-single:after{clear:both}.pika-single.is-hidden{display:none}.pika-single.is-bound{position:absolute;box-shadow:0 5px 15px -5px rgba(0,0,0,.5)}.pika-lendar{float:left;width:240px;margin:8px}.pika-title{position:relative;text-align:center}.pika-label{display:inline-block;position:relative;z-index:9999;overflow:hidden;margin:0;padding:5px 3px;font-size:14px;line-height:20px;font-weight:700;background-color:#fff}.pika-title select{cursor:pointer;position:absolute;z-index:9998;margin:0;left:0;top:5px;opacity:0}.pika-next,.pika-prev{display:block;cursor:pointer;position:relative;outline:0;border:0;padding:0;width:20px;height:30px;text-indent:20px;white-space:nowrap;overflow:hidden;background-color:transparent;background-position:center center;background-repeat:no-repeat;background-size:75% 75%;opacity:.5}.pika-next:hover,.pika-prev:hover{opacity:1}.is-rtl .pika-next,.pika-prev{float:left;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAUklEQVR42u3VMQoAIBADQf8Pgj+OD9hG2CtONJB2ymQkKe0HbwAP0xucDiQWARITIDEBEnMgMQ8S8+AqBIl6kKgHiXqQqAeJepBo/z38J/U0uAHlaBkBl9I4GwAAAABJRU5ErkJggg==)}.is-rtl .pika-prev,.pika-next{float:right;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAU0lEQVR42u3VOwoAMAgE0dwfAnNjU26bYkBCFGwfiL9VVWoO+BJ4Gf3gtsEKKoFBNTCoCAYVwaAiGNQGMUHMkjGbgjk2mIONuXo0nC8XnCf1JXgArVIZAQh5TKYAAAAASUVORK5CYII=)}.pika-next.is-disabled,.pika-prev.is-disabled{cursor:default;opacity:.2}.pika-select{display:inline-block}.pika-table{width:100%;border-collapse:collapse;border-spacing:0;border:0}.pika-table td,.pika-table th{width:14.285714285714286%;padding:0}.pika-table th{color:#999;font-size:12px;line-height:25px;font-weight:700;text-align:center}.pika-button{cursor:pointer;display:block;box-sizing:border-box;-moz-box-sizing:border-box;outline:0;border:0;margin:0;width:100%;padding:5px;color:#666;font-size:12px;line-height:15px;text-align:right;background:#f5f5f5}.pika-week{font-size:11px;color:#999}.is-today .pika-button{color:#3af;font-weight:700}.has-event .pika-button,.is-selected .pika-button{color:#fff;font-weight:700;background:#3af;box-shadow:inset 0 1px 3px #178fe5;border-radius:3px}.has-event .pika-button{background:#005da9;box-shadow:inset 0 1px 3px #0076c9}.is-disabled .pika-button,.is-inrange .pika-button{background:#d5e9f7}.is-startrange .pika-button{color:#fff;background:#6cb31d;box-shadow:none;border-radius:3px}.is-endrange .pika-button{color:#fff;background:#3af;box-shadow:none;border-radius:3px}.is-disabled .pika-button{pointer-events:none;cursor:default;color:#999;opacity:.3}.is-outside-current-month .pika-button{color:#999;opacity:.3}.is-selection-disabled{pointer-events:none;cursor:default}.pika-button:hover,.pika-row.pick-whole-week:hover .pika-button{color:#fff;background:#ff8000;box-shadow:none;border-radius:3px}.pika-table abbr{border-bottom:none;cursor:help}
/*# sourceMappingURL=pikaday.min.css.map */

BIN
static/fonts/ionicons.eot Normal file

Binary file not shown.

11
static/fonts/ionicons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

2230
static/fonts/ionicons.svg Normal file

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 326 KiB

BIN
static/fonts/ionicons.ttf Normal file

Binary file not shown.

BIN
static/fonts/ionicons.woff Normal file

Binary file not shown.

1
static/js/pikaday.min.js vendored Normal file

File diff suppressed because one or more lines are too long

5
static/js/theme.js Normal file
View file

@ -0,0 +1,5 @@
document.querySelectorAll('.datepicker').forEach(function(field) {
var picker = new Pikaday({
field: field
});
});

View file

View file

@ -0,0 +1,16 @@
"""
ASGI config for steeldonutcollection project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'steeldonutcollection.settings')
application = get_asgi_application()

View file

@ -0,0 +1,124 @@
from pathlib import Path
from autosecretkey import AutoSecretKey
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
SETTINGS_FILE = AutoSecretKey(
BASE_DIR / "settings.ini",
template=BASE_DIR/"settings.dist.ini")
SECRET_KEY = SETTINGS_FILE.secret_key
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'frontend',
'backend',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'steeldonutcollection.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / "templates"],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'django.contrib.auth.context_processors.auth',
],
},
},
]
WSGI_APPLICATION = 'steeldonutcollection.wsgi.application'
# Database
# https://docs.djangoproject.com/en/4.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.0/howto/static-files/
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / "static"]
# Default primary key field type
# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# S3 settings imported from AutoSecretKey config file
S3_ENDPOINT = SETTINGS_FILE.config["S3"]["Endpoint"]
S3_ACCESS_KEY = SETTINGS_FILE.config["S3"]["AccessKey"]
S3_SECRET_KEY = SETTINGS_FILE.config["S3"]["SecretKey"]
S3_BUCKET = SETTINGS_FILE.config["S3"]["Bucket"]

View file

@ -0,0 +1,7 @@
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('', include("frontend.urls")),
]

View file

@ -0,0 +1,16 @@
"""
WSGI config for steeldonutcollection project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'steeldonutcollection.settings')
application = get_wsgi_application()

View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no">
<title>{{title}} - Steel Donut Collection</title>
<meta name="description" content="The collection of Steel Donut Collective videos">
<link rel="stylesheet" href="/static/bootstrap/css/bootstrap.min.css">
<link rel="stylesheet" href="https://fontproxy.kumi.systems/css?family=Lato:300,400,700">
<link rel="stylesheet" href="/static/fonts/ionicons.min.css">
<link rel="stylesheet" href="/static/css/pikaday.min.css">
</head>
<body>
<nav class="navbar navbar-dark navbar-expand-lg fixed-top bg-white portfolio-navbar gradient">
<div class="container"><a class="navbar-brand logo" href="/">Steel Donut Collection</a><button data-bs-toggle="collapse" class="navbar-toggler" data-bs-target="#navbarNav"><span class="visually-hidden">Toggle navigation</span><span class="navbar-toggler-icon"></span></button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto"></ul>
</div>
</div>
</nav>
<main class="page projects-page">
{% block content %}
{% endblock %}
</main>
<footer class="page-footer">
<div class="container">
<div class="links"><a href="https://kumi.website">About me</a><a href="https://kumi.website">Contact me</a><a href="https://kumig.it/kumitterer/steeldonutcollection">Git</a></div>
</div>
</footer>
<script src="/static/bootstrap/js/bootstrap.min.js"></script>
<script src="/static/js/pikaday.min.js"></script>
<script src="/static/js/theme.js"></script>
</body>
</html>

View file

@ -0,0 +1,53 @@
{% extends "frontend/base.html" %}
{% block content %}
<section class="portfolio-block projects-with-sidebar">
<div class="container">
<div class="heading">
<h2>{{title}}</h2>
</div>
<div class="row">
<div class="col-md-3">
<ul class="list-unstyled sidebar">
<li><a class="active" href="/">All</a></li>
{% for playlist in playlists %}
<li><a href="{{playlist.get_absolute_url}}">{{playlist.title}}</a></li>
{% endfor %}
</ul>
</div>
<div class="col-md-9">
<div class="row">
{% for video in object_list %}
<div class="col-md-6 col-lg-4 project-sidebar-card"><a href="{{video.get_absolute_url}}"><img
class="img-fluid image scale-on-hover"
src="{{video.get_thumbnail_url}}">{{video.title}}</a></div>
{% endfor %}
</div>
</div>
<nav>
<ul class="pagination">
{% if not page_obj.number <= 2 %}
<li class="page-item">
<a class="page-link" href="?page=1" tabindex="-1">First<a>
</li>
{% endif %}
{% if page_obj.number != 1 %}
<li class="page-item">
<a class="page-link" href="?page={{page_obj.previous_page_number}}">{{page_obj.previous_page_number}}</a>
</li>
{% endif %}
<li class="page-item active">
<a class="page-link" href="?page={{page_obj.number}}">{{page_obj.number}}</a>
</li>
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{page_obj.next_page_number}}">{{page_obj.next_page_number}}</a></li>
<li class="page-item">
<a class="page-link" href="?page={{page_obj.paginator.num_pages}}">Last</a>
</li>
{% endif %}
</ul>
</nav>
</div>
</div>
</section>
{% endblock content %}

View file

@ -0,0 +1,40 @@
{% extends "frontend/base.html" %}
{% load random %}
{% block content %}
<section class="portfolio-block project">
<div class="container">
<div class="heading">
<h2>{{object.title}}</h2>
</div>
<div class="image"><video controls poster="{{object.get_thumbnail_url}}" src="{{object.get_video_url}}" width="100%" height="100%"></div>
<div class="row">
<div class="col-12 col-md-6 offset-md-1 info">
<h3>Description</h3>
<p>{{object.description|linebreaksbr}}</p>
</div>
<div class="col-12 col-md-3 offset-md-1 meta">
<div class="tags"><span class="meta-heading">Tags</span>
{% for tag in object.tags %}
<a href="#">{{tag}}</a>
{% endfor %}
</div>
<hr>
<div class="tags"><span class="meta-heading">Date</span>
{{ object.published.date }}
</div>
</div>
</div>
<div class="more-projects">
<h3 class="text-center">Random Videos</h3>
<div class="row gallery">
{% random_videos as suggestions %}
{% for video in suggestions %}
<div class="col-md-4 col-lg-3">
<div class="item"><a href="{{video.get_absolute_url}}"><img class="img-fluid scale-on-hover" src="{{video.get_thumbnail_url}}">{{video.title}}</a></div>
</div>
{% endfor %}
</div>
</div>
</div>
</section>
{% endblock %}