commit b420bf43b72881ceb2e16a1d7ef35179d68d291e Author: j Date: Sat Jul 15 13:00:36 2023 +0530 embeded pandora mobile view diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..367533a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +venv +*.swp +__pycache__ +db.sqlite3 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/asgi.py b/app/asgi.py new file mode 100644 index 0000000..3296932 --- /dev/null +++ b/app/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for app 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.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + +application = get_asgi_application() diff --git a/app/item/__init__.py b/app/item/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/item/admin.py b/app/item/admin.py new file mode 100644 index 0000000..1722428 --- /dev/null +++ b/app/item/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin + +from . import models + + +class ItemAdmin(admin.ModelAdmin): + search_fields = ['title', 'description', 'url'] + list_display = ['__str__', 'id', 'published'] + list_filter = ( + ("published", admin.EmptyFieldListFilter), + ) + +admin.site.register(models.Item, ItemAdmin) + + +class CommentAdmin(admin.ModelAdmin): + search_fields = ['item__title', 'item__url', 'text', 'name', 'email'] + list_display = ['__str__', 'published'] + list_filter = ( + ("published", admin.EmptyFieldListFilter), + ) + +admin.site.register(models.Comment, CommentAdmin) diff --git a/app/item/apps.py b/app/item/apps.py new file mode 100644 index 0000000..e703b3b --- /dev/null +++ b/app/item/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ItemConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "app.item" diff --git a/app/item/migrations/0001_initial.py b/app/item/migrations/0001_initial.py new file mode 100644 index 0000000..eb624c0 --- /dev/null +++ b/app/item/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.3 on 2023-07-11 16:06 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Comment", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("name", models.CharField(max_length=1024)), + ("email", models.CharField(max_length=1024)), + ("text", models.TextField(blank=True, default="")), + ("data", models.JSONField(default=dict, editable=False)), + ("published", models.DateTimeField(default=None, null=True)), + ], + ), + migrations.CreateModel( + name="Item", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("url", models.CharField(max_length=1024, unique=True)), + ("title", models.CharField(max_length=1024)), + ("description", models.TextField(blank=True, default="")), + ("published", models.DateTimeField(default=datetime.datetime.now)), + ("announced", models.DateTimeField(default=None, null=True)), + ("data", models.JSONField(default=dict, editable=False)), + ], + ), + migrations.CreateModel( + name="Settings", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created", models.DateTimeField(auto_now_add=True)), + ("modified", models.DateTimeField(auto_now=True)), + ("key", models.CharField(max_length=1024, unique=True)), + ("value", models.JSONField(default=dict, editable=False)), + ], + ), + ] diff --git a/app/item/migrations/0002_initial.py b/app/item/migrations/0002_initial.py new file mode 100644 index 0000000..5c45ac4 --- /dev/null +++ b/app/item/migrations/0002_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.3 on 2023-07-11 16:06 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("item", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="item", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="items", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="comment", + name="item", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to="item.item", + ), + ), + migrations.AddField( + model_name="comment", + name="user", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="comments", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/app/item/migrations/__init__.py b/app/item/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/item/models.py b/app/item/models.py new file mode 100644 index 0000000..0bb4f8b --- /dev/null +++ b/app/item/models.py @@ -0,0 +1,104 @@ +from django.utils.timezone import datetime, timedelta +from django.utils import timezone +import json + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.db import models +from django.urls import reverse + + +User = get_user_model() + + +class Settings(models.Model): + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + key = models.CharField(max_length=1024, unique=True) + value = models.JSONField(default=dict, editable=False) + + +class Item(models.Model): + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + user = models.ForeignKey(User, null=True, related_name='items', on_delete=models.CASCADE) + + url = models.CharField(max_length=1024, unique=True) + title = models.CharField(max_length=1024) + description = models.TextField(default="", blank=True) + published = models.DateTimeField(default=timezone.now, null=True, blank=True) + announced = models.DateTimeField(null=True, default=None, blank=True) + data = models.JSONField(default=dict, editable=False) + + def __str__(self): + return '%s (%s)' % (self.title, self.url) + + def public_comments(self): + return self.comments.exclude(published=None) + + def public_comments_json(self): + comments = [] + for comment in self.public_comments(): + comments.append({ + "name": comment.name, + "date": comment.date, + "text": comment.text, + }) + return json.dumps(comments) + + @classmethod + def public(cls): + now = timezone.now() + qs = cls.objects.exclude(published=None).filter(published__lte=now).order_by('-published') + cal = now.date().isocalendar() + monday = now.date() - timedelta(days=now.date().isocalendar().weekday - 1) + monday = timezone.datetime(monday.year, monday.month, monday.day, tzinfo=now.tzinfo) + print(now.tzinfo) + first_post = qs.filter(published__gt=monday).first() + print('!!', first_post.published, now, first_post.published > now) + if first_post.published < now: + print('only this week') + week = qs.filter(published__gt=monday) + else: + print('only last week') + last_monday = monday - timedelta(days=7) + week = qs.filter(published__gt=last_monday) + archive = qs.exclude(id__in=week) + return week, archive + + def get_absolute_url(self): + return reverse('item', kwargs={'id': self.id}) + +class Comment(models.Model): + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + item = models.ForeignKey(Item, related_name='comments', on_delete=models.CASCADE) + user = models.ForeignKey(User, null=True, related_name='comments', on_delete=models.CASCADE, blank=True) + + name = models.CharField(max_length=1024) + email = models.CharField(max_length=1024) + text = models.TextField(default="", blank=True) + data = models.JSONField(default=dict, editable=False) + published = models.DateTimeField(null=True, default=None) + + def __str__(self): + return '%s: %s' % (self.item, self.user) + + def save(self, *args, **kwargs): + if self.user: + self.name = self.user.username + self.email = self.user.email + super().save(*args, **kwargs) + + @property + def date(self): + return self.created.strftime('%Y-%m-%d %H:%M') + + def json(self): + data = {} + data['name'] = self.name + data['date'] = self.date + data['text'] = self.text + return data diff --git a/app/item/tests.py b/app/item/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/item/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/item/views.py b/app/item/views.py new file mode 100644 index 0000000..7a28ef9 --- /dev/null +++ b/app/item/views.py @@ -0,0 +1,51 @@ +from datetime import date, datetime, timedelta +from django.shortcuts import render + +from . import models + + +def index(request): + context = {} + week, archive = models.Item.public() + context['items'] = week + context['archive'] = archive.exists() + return render(request, 'index.html', context) + + +def archive(request): + context = {} + qs = models.Item.public() + week, archive = models.Item.public() + context['items'] = archive + return render(request, 'archive.html', context) + +def item(request, id): + context = {} + item = models.Item.objects.get(id=id) + context['item'] = item + return render(request, 'item.html', context) + +import json +from django.http import Http404, HttpResponse + +def render_to_json(response): + content = json.dumps(response) + return HttpResponse(content, 'application/json; charset=utf-8') + + +def comment(request): + response = {} + data = json.loads(request.body) + print(data) + comment = models.Comment() + comment.item = models.Item.objects.get(id=data['item']) + if request.user.is_authenticated: + comment.user = request.user + comment.published = datetime.now() + else: + comment.name = data['name'] + comment.email = data['email'] + comment.text = data['text'] + comment.save() + response = comment.json() + return render_to_json(response) diff --git a/app/settings.py b/app/settings.py new file mode 100644 index 0000000..0d5222a --- /dev/null +++ b/app/settings.py @@ -0,0 +1,128 @@ +""" +Django settings for app project. + +Generated by 'django-admin startproject' using Django 4.2.3. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-()6&rdheil=iyz%36dl-fnb)a+*7*^cb%isz6x%fi+ong5#*zz" + +# 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", + + "app", + "app.user", + "app.item", +] + +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 = "app.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "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", + ], + }, + }, +] + +WSGI_APPLICATION = "app.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/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.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = 'Asia/Kolkata' +USE_I18N = True +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + + +AUTH_USER_MODEL = 'user.User' diff --git a/app/static/css/reset.css b/app/static/css/reset.css new file mode 100644 index 0000000..8e9c2eb --- /dev/null +++ b/app/static/css/reset.css @@ -0,0 +1,46 @@ +/* http://meyerweb.com/eric/tools/css/reset/ + v2.0 | 20110126 + License: none (public domain) +*/ + +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +u, i, center, +ol, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + vertical-align: baseline; +} +/* HTML5 display-role reset for older browsers */ +article, aside, details, figcaption, figure, +footer, header, hgroup, menu, nav, section { + display: block; +} +body { + line-height: 1; + +} +blockquote, q { + quotes: none; +} +blockquote:before, blockquote:after, +q:before, q:after { + content: ''; + content: none; +} +table { + border-collapse: collapse; + border-spacing: 0; +} diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..61d30dc --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,171 @@ +body { + margin: 0; + //background: rgb(240, 240, 240); + //background: rgb(144, 144, 144); + //color: rgb(0, 0, 0); + background: rgb(16, 16, 16); + color: rgb(240, 240, 240); + font-family: "Noto Sans", "Lucida Grande", "Segoe UI", "DejaVu Sans", "Lucida Sans Unicode", Helvetica, Arial, sans-serif; + line-height: normal; +} + +a { + color: rgb(128, 128, 255) +} +iframe { + max-width: 100%; +} + +.player { + max-width: 100%; +} +video, .poster { + border-bottom: 1px solid yellow; +} +.content { + display: flex; + flex-direction: column; + min-height: 100vh; + max-width: 1000px; + margin: auto; +} +.title { + padding-top: 16px; + padding-bottom: 0; + margin-bottom: 4px; + font-size: 14pt; + font-weight: bold; + border-bottom: 1px solid pink; + text-wrap: balance; +} +.byline { + padding: 0; + padding-bottom: 16px; +} +.player { + text-align: center; + padding-top: 0px; + padding-bottom: 0px; +} +@media(max-width:768px) { + .title, + .byline { + padding-left: 4px; + padding-right: 4px; + } + .player { + position: sticky; + top: 0px; + } +} +.player video { + z-index: 0; +} +.value { + padding: 4px; + padding-top: 16px; + padding-left: 0; + padding-right: 0; + flex: 1; +} +@media(max-width:768px) { + .value { + padding-left: 4px; + padding-right: 4px; + } +} +.more { + padding-top: 16px; + padding-bottom: 16px; + text-align: center; + font-size: 16pt; +} +.layer.active { + padding-top: 8px; +} +.layer.active:first-child { + padding-top: 0px; +} + +.annotation { + padding: 4px; + border-bottom: 1px solid blueviolet; + display: none; +} +.annotation.active { + display: block; +} +.annotation.active.place, +.annotation.active.string { + display: inline-block; + border: 1px solid blueviolet; + margin-bottom: 4px; +} +@media(max-width:768px) { + .annotation a img { + width: 100%; + } +} + +.layer h3, +.comments h3 { + font-weight: bold; + padding: 4px; + padding-left: 0; + margin: 0; + //display: none; + cursor: pointer; +} +.layer.active h3 { + display: block; +} +.layer .icon svg, +.comments .icon svg { + width: 12px; + height: 12px; +} +.layer.collapsed .annotation.active { + display: none; +} +.rotate { + transform: rotate(90deg) translateY(-100%); + transform-origin:bottom left; +} + +.document.text { + line-height: 1.5; + letter-spacing: 0.1px; + word-wrap: break-word; + hyphens: auto; +} +.document.text p { + padding-bottom: 1em; +} +.document.text img { + max-width: 100vw; + margin-left: -4px; + margin-right: -4px; +} +figure { + text-align: center; +} +figure img { + max-width: 100vw; + margin-left: -4px; + margin-right: -4px; +} +@media(max-width:768px) { +.document.text { + padding-left: 4px; + padding-right: 4px; +} +} + +ol li { + margin-left: 1em; +} + +.blocked svg { + width: 64px; + height: 64px; +} diff --git a/app/static/js/VideoElement.js b/app/static/js/VideoElement.js new file mode 100644 index 0000000..8238d95 --- /dev/null +++ b/app/static/js/VideoElement.js @@ -0,0 +1,730 @@ +'use strict'; + +/*@ +VideoElement VideoElement Object + options Options object + autoplay autoplay + items array of objects with src,in,out,duration + loop loop playback + playbackRate playback rate + position start position + self Shared private variable + ([options[, self]]) -> VideoElement Object + loadedmetadata loadedmetadata + itemchange itemchange + seeked seeked + seeking seeking + sizechange sizechange + ended ended +@*/ + +(function() { + var queue = [], + queueSize = 100, + restrictedElements = [], + requiresUserGesture = mediaPlaybackRequiresUserGesture(), + unblock = []; + + +window.VideoElement = function(options) { + + var self = {}, + that = document.createElement("div"); + + self.options = { + autoplay: false, + items: [], + loop: false, + muted: false, + playbackRate: 1, + position: 0, + volume: 1 + } + Object.assign(self.options, options); + debug(self.options) + + that.style.position = "relative" + that.style.width = "100%" + that.style.height = "100%" + that.style.maxHeight = "100vh" + that.style.margin = 'auto' + if (self.options.aspectratio) { + that.style.aspectRatio = self.options.aspectratio + } else { + that.style.height = '128px' + } + that.triggerEvent = function(event, data) { + if (event != 'timeupdate') { + debug('Video', 'triggerEvent', event, data); + } + event = new Event(event) + event.data = data + that.dispatchEvent(event) + } + + + /* + .update({ + items: function() { + self.loadedMetadata = false; + loadItems(function() { + self.loadedMetadata = true; + var update = true; + if (self.currentItem >= self.numberOfItems) { + self.currentItem = 0; + } + if (!self.numberOfItems) { + self.video.src = ''; + that.triggerEvent('durationchange', { + duration: that.duration() + }); + } else { + if (self.currentItemId != self.items[self.currentItem].id) { + // check if current item is in new items + self.items.some(function(item, i) { + if (item.id == self.currentItemId) { + self.currentItem = i; + loadNextVideo(); + update = false; + return true; + } + }); + if (update) { + self.currentItem = 0; + self.currentItemId = self.items[self.currentItem].id; + } + } + if (!update) { + that.triggerEvent('seeked'); + that.triggerEvent('durationchange', { + duration: that.duration() + }); + } else { + setCurrentVideo(function() { + that.triggerEvent('seeked'); + that.triggerEvent('durationchange', { + duration: that.duration() + }); + }); + } + } + }); + }, + playbackRate: function() { + self.video.playbackRate = self.options.playbackRate; + } + }) + .css({width: '100%', height: '100%'}); + */ + + debug('Video', 'VIDEO ELEMENT OPTIONS', self.options); + + self.currentItem = -1; + self.currentTime = 0; + self.currentVideo = 0; + self.items = []; + self.loadedMetadata = false; + that.paused = self.paused = true; + self.seeking = false; + self.loading = true; + self.buffering = true; + self.videos = [getVideo(), getVideo()]; + self.video = self.videos[self.currentVideo]; + self.video.classList.add("active") + self.volume = self.options.volume; + self.muted = self.options.muted; + self.brightness = document.createElement('div') + self.brightness.style.top = '0' + self.brightness.style.left = '0' + self.brightness.style.width = '100%' + self.brightness.style.height = '100%' + self.brightness.style.background = 'rgb(0, 0, 0)' + self.brightness.style.opacity = '0' + self.brightness.style.position = "absolute" + that.append(self.brightness) + + self.timeupdate = setInterval(function() { + if (!self.paused + && !self.loading + && self.loadedMetadata + && self.items[self.currentItem] + && self.items[self.currentItem].out + && self.video.currentTime >= self.items[self.currentItem].out) { + setCurrentItem(self.currentItem + 1); + } + }, 30); + + // mobile browsers only allow playing media elements after user interaction + if (restrictedElements.length > 0) { + unblock.push(setSource) + setTimeout(function() { + that.triggerEvent('requiresusergesture'); + }) + } else { + setSource(); + } + + function getCurrentTime() { + var item = self.items[self.currentItem]; + return self.seeking || self.loading + ? self.currentTime + : item ? item.position + self.video.currentTime - item['in'] : 0; + } + + function getset(key, value) { + var ret; + if (isUndefined(value)) { + ret = self.video[key]; + } else { + self.video[key] = value; + ret = that; + } + return ret; + } + + function getVideo() { + var video = getVideoElement() + video.style.display = "none" + video.style.width = "100%" + video.style.height = "100%" + video.style.margin = "auto" + video.style.background = '#000' + if (self.options.aspectratio) { + video.style.aspectRatio = self.options.aspectratio + } else { + video.style.height = '128px' + } + video.style.top = 0 + video.style.left = 0 + video.style.position = "absolute" + video.preload = "metadata" + video.addEventListener("ended", event => { + if (self.video == video) { + setCurrentItem(self.currentItem + 1); + } + }) + video.addEventListener("loadedmetadata", event => { + //console.log("!!", video.src, "loaded", 'current?', video == self.video) + }) + video.addEventListener("progress", event => { + // stop buffering if buffered to end point + var item = self.items[self.currentItem], + nextItem = mod(self.currentItem + 1, self.numberOfItems), + next = self.items[nextItem], + nextVideo = self.videos[mod(self.currentVideo + 1, self.videos.length)]; + if (self.video == video && (video.preload != 'none' || self.buffering)) { + if (clipCached(video, item)) { + video.preload = 'none'; + self.buffering = false; + if (nextItem != self.currentItem) { + nextVideo.preload = 'auto'; + } + } else { + if (nextItem != self.currentItem && nextVideo.preload != 'none' && nextVideo.src) { + nextVideo.preload = 'none'; + } + } + } else if (nextVideo == video && video.preload != 'none' && nextVideo.src) { + if (clipCached(video, next)) { + video.preload = 'none'; + } + } + + function clipCached(video, item) { + var cached = false + for (var i=0; i= item.out) { + cached = true + } + } + return cached + } + }) + video.addEventListener("volumechange", event => { + if (self.video == video) { + that.triggerEvent('volumechange') + } + }) + video.addEventListener("play", event => { + /* + if (self.video == video) { + that.triggerEvent('play') + } + */ + }) + video.addEventListener("pause", event => { + /* + if (self.video == video) { + that.triggerEvent('pause') + } + */ + }) + video.addEventListener("timeupdate", event => { + if (self.video == video) { + /* + var box = self.video.getBoundingClientRect() + if (box.width && box.height) { + that.style.width = box.width + 'px' + that.style.height = box.height + 'px' + } + */ + that.triggerEvent('timeupdate', { + currentTime: getCurrentTime() + }) + } + }) + video.addEventListener("seeking", event => { + if (self.video == video) { + that.triggerEvent('seeking') + } + }) + video.addEventListener("stop", event => { + if (self.video == video) { + //self.video.pause(); + that.triggerEvent('ended'); + } + }) + that.append(video) + return video + } + + function getVideoElement() { + var video; + if (requiresUserGesture) { + if (queue.length) { + video = queue.pop(); + } else { + video = document.createElement('video'); + restrictedElements.push(video); + } + } else { + video = document.createElement('video'); + } + video.playsinline = true + video.setAttribute('playsinline', 'playsinline') + video.setAttribute('webkit-playsinline', 'webkit-playsinline') + video.WebKitPlaysInline = true + return video + }; + + function getVolume() { + var volume = 1; + if (self.items[self.currentItem] && isNumber(self.items[self.currentItem].volume)) { + volume = self.items[self.currentItem].volume; + } + return self.volume * volume; + } + + + function isReady(video, callback) { + if (video.seeking && !self.paused && !self.seeking) { + that.triggerEvent('seeking'); + debug('Video', 'isReady', 'seeking'); + video.addEventListener('seeked', function(event) { + debug('Video', 'isReady', 'seeked'); + that.triggerEvent('seeked'); + callback(video); + }, {once: true}) + } else if (video.readyState) { + callback(video); + } else { + that.triggerEvent('seeking'); + video.addEventListener('loadedmetadata', function(event) { + callback(video); + }, {once: true}); + video.addEventListener('seeked', event => { + that.triggerEvent('seeked'); + }, {once: true}) + } + } + + function loadItems(callback) { + debug('loadItems') + var currentTime = 0, + items = self.options.items.map(function(item) { + return isObject(item) ? {...item} : {src: item}; + }); + + self.items = items; + self.numberOfItems = self.items.length; + items.forEach(item => { + item['in'] = item['in'] || 0; + item.position = currentTime; + if (item.out) { + item.duration = item.out - item['in']; + } + if (item.duration) { + if (!item.out) { + item.out = item.duration; + } + currentTime += item.duration; + item.id = getId(item); + } else { + getVideoInfo(item.src, function(info) { + item.duration = info.duration; + if (!item.out) { + item.out = item.duration; + } + currentTime += item.duration; + item.id = getId(item); + }); + } + }) + debug('loadItems done') + callback && callback(); + + function getId(item) { + return item.id || item.src + '/' + item['in'] + '-' + item.out; + } + } + + function loadNextVideo() { + if (self.numberOfItems <= 1) { + return; + } + var item = self.items[self.currentItem], + nextItem = mod(self.currentItem + 1, self.numberOfItems), + next = self.items[nextItem], + nextVideo = self.videos[mod(self.currentVideo + 1, self.videos.length)]; + nextVideo.addEventListener('loadedmetadata', function() { + if (self.video != nextVideo) { + nextVideo.currentTime = next['in'] || 0; + } + }, {once: true}); + nextVideo.src = next.src; + nextVideo.preload = 'metadata'; + } + + function setCurrentItem(item) { + debug('Video', 'sCI', item, self.numberOfItems); + var interval; + if (item >= self.numberOfItems || item < 0) { + if (self.options.loop) { + item = mod(item, self.numberOfItems); + } else { + self.seeking = false; + self.ended = true; + that.paused = self.paused = true; + self.video && self.video.pause(); + that.triggerEvent('ended'); + return; + } + } + self.video && self.video.pause(); + self.currentItem = item; + self.currentItemId = self.items[self.currentItem].id; + setCurrentVideo(function() { + if (!self.loadedMetadata) { + self.loadedMetadata = true; + that.triggerEvent('loadedmetadata'); + } + debug('Video', 'sCI', 'trigger itemchange', + self.items[self.currentItem]['in'], self.video.currentTime, self.video.seeking); + that.triggerEvent('sizechange'); + that.triggerEvent('itemchange', { + item: self.currentItem + }); + }); + } + + function setCurrentVideo(callback) { + var css = {}, + muted = self.muted, + item = self.items[self.currentItem], + next; + debug('Video', 'sCV', JSON.stringify(item)); + + ['left', 'top', 'width', 'height'].forEach(function(key) { + css[key] = self.videos[self.currentVideo].style[key]; + }); + self.currentTime = item.position; + self.loading = true; + if (self.video) { + self.videos[self.currentVideo].style.display = "none" + self.videos[self.currentVideo].classList.remove("active") + self.video.pause(); + } + self.currentVideo = mod(self.currentVideo + 1, self.videos.length); + self.video = self.videos[self.currentVideo]; + self.video.classList.add("active") + self.video.muted = true; // avoid sound glitch during load + if (!self.video.attributes.src || self.video.attributes.src.value != item.src) { + self.loadedMetadata && debug('Video', 'caching next item failed, reset src'); + self.video.src = item.src; + } + self.video.preload = 'metadata'; + self.video.volume = getVolume(); + self.video.playbackRate = self.options.playbackRate; + Object.keys(css).forEach(key => { + self.video.style[key] = css[key] + }) + self.buffering = true; + debug('Video', 'sCV', self.video.src, item['in'], + self.video.currentTime, self.video.seeking); + isReady(self.video, function(video) { + var in_ = item['in'] || 0; + + function ready() { + debug('Video', 'sCV', 'ready'); + self.seeking = false; + self.loading = false; + self.video.muted = muted; + !self.paused && self.video.play(); + self.video.style.display = 'block' + callback && callback(); + loadNextVideo(); + } + if (video.currentTime == in_) { + debug('Video', 'sCV', 'already at position', item.id, in_, video.currentTime); + ready(); + } else { + self.video.addEventListener("seeked", event => { + debug('Video', 'sCV', 'seeked callback'); + ready(); + }, {once: true}) + if (!self.seeking) { + debug('Video', 'sCV set in', video.src, in_, video.currentTime, video.seeking); + self.seeking = true; + video.currentTime = in_; + if (self.paused) { + var promise = self.video.play(); + if (promise !== undefined) { + promise.then(function() { + self.video.pause(); + self.video.muted = muted; + }).catch(function() { + self.video.pause(); + self.video.muted = muted; + }); + } else { + self.video.pause(); + self.video.muted = muted; + } + } + } + } + }); + } + + function setCurrentItemTime(currentTime) { + debug('Video', 'sCIT', currentTime, self.video.currentTime, + 'delta', currentTime - self.video.currentTime); + isReady(self.video, function(video) { + if (self.video == video) { + if(self.video.seeking) { + self.video.addEventListener("seeked", event => { + that.triggerEvent('seeked'); + self.seeking = false; + }, {once: true}) + } else if (self.seeking) { + that.triggerEvent('seeked'); + self.seeking = false; + } + video.currentTime = currentTime; + } + }); + } + + function setCurrentTime(time) { + debug('Video', 'sCT', time); + var currentTime, currentItem; + self.items.forEach(function(item, i) { + if (time >= item.position + && time < item.position + item.duration) { + currentItem = i; + currentTime = time - item.position + item['in']; + return false; + } + }); + if (self.items.length) { + // Set to end of items if time > duration + if (isUndefined(currentItem) && isUndefined(currentTime)) { + currentItem = self.items.length - 1; + currentTime = self.items[currentItem].duration + self.items[currentItem]['in']; + } + debug('Video', 'sCT', time, '=>', currentItem, currentTime); + if (currentItem != self.currentItem) { + setCurrentItem(currentItem); + } + self.seeking = true; + self.currentTime = time; + that.triggerEvent('seeking'); + setCurrentItemTime(currentTime); + } else { + self.currentTime = 0; + } + } + + function setSource() { + self.loadedMetadata = false; + loadItems(function() { + setCurrentTime(self.options.position); + self.options.autoplay && setTimeout(function() { + that.play(); + }); + }); + } + + + /*@ + brightness get/set brightness + @*/ + that.brightness = function() { + var ret; + if (arguments.length == 0) { + ret = 1 - parseFloat(self.brightness.style.opacity); + } else { + self.brightness.style.opacity = 1 - arguments[0] + ret = that; + } + return ret; + }; + + /*@ + buffered buffered + @*/ + that.buffered = function() { + return self.video.buffered; + }; + + /*@ + currentTime get/set currentTime + @*/ + that.currentTime = function() { + var ret; + if (arguments.length == 0) { + ret = getCurrentTime(); + } else { + self.ended = false; + setCurrentTime(arguments[0]); + ret = that; + } + return ret; + }; + + /*@ + duration duration + @*/ + that.duration = function() { + return self.items ? self.items.reduce((duration, item) => { + return duration + item.duration; + }, 0) : NaN; + }; + + /*@ + muted get/set muted + @*/ + that.muted = function(value) { + if (!isUndefined(value)) { + self.muted = value; + } + return getset('muted', value); + }; + + /*@ + pause pause + @*/ + that.pause = function() { + that.paused = self.paused = true; + self.video.pause(); + that.paused && that.triggerEvent('pause') + }; + + /*@ + play play + @*/ + that.play = function() { + if (self.ended) { + that.currentTime(0); + } + isReady(self.video, function(video) { + self.ended = false; + that.paused = self.paused = false; + self.seeking = false; + video.play(); + that.triggerEvent('play') + }); + }; + + that.removeElement = function() { + self.currentTime = getCurrentTime(); + self.loading = true; + clearInterval(self.timeupdate); + //Chrome does not properly release resources, reset manually + //http://code.google.com/p/chromium/issues/detail?id=31014 + self.videos.forEach(function(video) { + video.src = '' + }); + return Ox.Element.prototype.removeElement.apply(that, arguments); + }; + + /*@ + videoHeight get videoHeight + @*/ + that.videoHeight = function() { + return self.video.videoHeight; + }; + + /*@ + videoWidth get videoWidth + @*/ + that.videoWidth = function() { + return self.video.videoWidth; + }; + + /*@ + volume get/set volume + @*/ + that.volume = function(value) { + if (isUndefined(value)) { + value = self.volume + } else { + self.volume = value; + self.video.volume = getVolume(); + } + return value; + }; + + return that; + +}; + +// mobile browsers only allow playing media elements after user interaction + + function mediaPlaybackRequiresUserGesture() { + // test if play() is ignored when not called from an input event handler + var video = document.createElement('video'); + video.muted = true + video.play(); + return video.paused; + } + + + async function removeBehaviorsRestrictions() { + debug('Video', 'remove restrictions on video', self.video, restrictedElements.length, queue.length); + if (restrictedElements.length > 0) { + var rElements = restrictedElements; + restrictedElements = []; + rElements.forEach(async function(video) { + await video.load(); + }); + setTimeout(function() { + var u = unblock; + unblock = []; + u.forEach(function(callback) { callback(); }); + }, 1000); + } + while (queue.length < queueSize) { + var video = document.createElement('video'); + video.load(); + queue.push(video); + } + } + + if (requiresUserGesture) { + window.addEventListener('keydown', removeBehaviorsRestrictions); + window.addEventListener('mousedown', removeBehaviorsRestrictions); + window.addEventListener('touchstart', removeBehaviorsRestrictions); + } +})(); diff --git a/app/static/js/VideoPlayer.js b/app/static/js/VideoPlayer.js new file mode 100644 index 0000000..829fc02 --- /dev/null +++ b/app/static/js/VideoPlayer.js @@ -0,0 +1,400 @@ +(function() { + +window.VideoPlayer = function(options) { + + var self = {}, that; + self.options = { + autoplay: false, + controls: true, + items: [], + loop: false, + muted: false, + playbackRate: 1, + position: 0, + volume: 1 + } + Object.assign(self.options, options); + that = VideoElement(options); + + self.controls = document.createElement('div') + self.controls.classList.add('mx-controls') + //self.controls.style.display = "none" + if (self.options.controls) { + var ratio = `aspect-ratio: ${self.options.aspectratio};` + if (!self.options.aspectratio) { + ratio = 'height: 128px;' + } + self.controls.innerHTML = ` + +
+
${icon.play}
+
+
+
+ ${icon.mute} +
+
+
+
+ +
+
+
+
+
+
+ ${isIOS || !self.options.aspectratio ? "" : icon.enterFullscreen} +
+
+ ` + var toggleVideo = event => { + event.preventDefault() + event.stopPropagation() + if (that.paused) { + that.play() + } else { + that.pause() + } + } + async function toggleFullscreen(event) { + if (isIOS) { + return + } + event.preventDefault() + event.stopPropagation() + if (!document.fullscreenElement) { + that.classList.add('fullscreen') + if (that.webkitRequestFullscreen) { + await that.webkitRequestFullscreen() + } else { + await that.requestFullscreen() + } + console.log('entered fullscreen') + var failed = false + if (!screen.orientation.type.startsWith("landscape")) { + await screen.orientation.lock("landscape").catch(err => { + console.log('no luck with lock', err) + /* + document.querySelector('.error').innerHTML = '' + err + that.classList.remove('fullscreen') + document.exitFullscreen(); + screen.orientation.unlock() + failed = true + */ + }) + } + if (that.paused && !failed) { + that.play() + } + } else { + that.classList.remove('fullscreen') + document.exitFullscreen(); + screen.orientation.unlock() + } + } + var toggleSound = event => { + event.preventDefault() + event.stopPropagation() + if (that.muted()) { + that.muted(false) + } else { + that.muted(true) + } + } + var showControls + var toggleControls = event => { + if (self.controls.style.opacity == '0') { + event.preventDefault() + event.stopPropagation() + self.controls.style.opacity = '1' + showControls = setTimeout(() => { + self.controls.style.opacity = that.paused ? '1' : '0' + showControls = null + }, 3000) + } else { + self.controls.style.opacity = '0' + } + } + self.controls.addEventListener("mousemove", event => { + if (showControls) { + clearTimeout(showControls) + } + self.controls.style.opacity = '1' + showControls = setTimeout(() => { + self.controls.style.opacity = that.paused ? '1' : '0' + showControls = null + }, 3000) + }) + self.controls.addEventListener("mouseleave", event => { + if (showControls) { + clearTimeout(showControls) + } + self.controls.style.opacity = that.paused ? '1' : '0' + showControls = null + }) + self.controls.addEventListener("touchstart", toggleControls) + self.controls.querySelector('.toggle').addEventListener("click", toggleVideo) + self.controls.querySelector('.volume').addEventListener("click", toggleSound) + self.controls.querySelector('.fullscreen-btn').addEventListener("click", toggleFullscreen) + document.addEventListener('fullscreenchange', event => { + if (!document.fullscreenElement) { + screen.orientation.unlock() + that.classList.remove('fullscreen') + that.querySelector('.fullscreen-btn').innerHTML = icon.enterFullscreen + } else { + self.controls.querySelector('.fullscreen-btn').innerHTML = icon.exitFullscreen + } + }) + that.append(self.controls) + } + + function getVideoWidth() { + if (document.fullscreenElement) { + return '' + } + var av = that.querySelector('video.active') + return av ? av.getBoundingClientRect().width + 'px' : '100%' + } + + var playOnLoad = false + var unblock = document.createElement("div") + + that.addEventListener("requiresusergesture", event => { + unblock.style.position = "absolute" + unblock.style.width = '100%' + unblock.style.height = '100%' + unblock.style.backgroundImage = `url(${self.options.poster})` + unblock.style.zIndex = '1000' + unblock.style.backgroundPosition = "top left" + unblock.style.backgroundRepeat = "no-repeat" + unblock.style.backgroundSize = "cover" + unblock.style.display = 'flex' + unblock.classList.add('mx-controls') + unblock.classList.add('poster') + unblock.innerHTML = ` +
+
${icon.play}
+
+
+ ` + self.controls.style.opacity = '0' + unblock.addEventListener("click", event => { + event.preventDefault() + event.stopPropagation() + playOnLoad = true + unblock.querySelector('.toggle').innerHTML = ` +
${icon.loading}
+ ` + }, {once: true}) + that.append(unblock) + }) + var loading = true + that.brightness(0) + that.addEventListener("loadedmetadata", event => { + // + }) + that.addEventListener("seeked", event => { + if (loading) { + that.brightness(1) + loading = false + } + if (playOnLoad) { + playOnLoad = false + var toggle = self.controls.querySelector('.toggle') + toggle.title = 'Pause' + toggle.querySelector('div').innerHTML = icon.pause + self.controls.style.opacity = '0' + unblock.remove() + that.play() + } + }) + + var time = that.querySelector('.controls .time div'), + progress = that.querySelector('.controls .position .progress') + that.querySelector('.controls .position').addEventListener("click", event => { + var bar = event.target + while (bar && !bar.classList.contains('bar')) { + bar = bar.parentElement + } + if (bar && bar.classList.contains('bar')) { + event.preventDefault() + event.stopPropagation() + var rect = bar.getBoundingClientRect() + var x = event.clientX - rect.x + var percent = x / rect.width + var position = percent * self.options.duration + if (self.options.position) { + position += self.options.position + } + progress.style.width = (100 * percent) + '%' + that.currentTime(position) + } + }) + that.addEventListener("timeupdate", event => { + var currentTime = that.currentTime(), + duration = self.options.duration + if (self.options.position) { + currentTime -= self.options.position + } + progress.style.width = (100 * currentTime / duration) + '%' + duration = formatDuration(duration) + currentTime = formatDuration(currentTime) + while (duration && duration.startsWith('00:')) { + duration = duration.slice(3) + } + currentTime = currentTime.slice(currentTime.length - duration.length) + time.innerText = `${currentTime} / ${duration}` + + }) + + that.addEventListener("play", event => { + var toggle = self.controls.querySelector('.toggle') + toggle.title = 'Pause' + toggle.querySelector('div').innerHTML = icon.pause + self.controls.style.opacity = '0' + }) + that.addEventListener("pause", event => { + var toggle = self.controls.querySelector('.toggle') + toggle.title = 'Play' + toggle.querySelector('div').innerHTML = icon.play + self.controls.style.opacity = '1' + }) + that.addEventListener("ended", event => { + var toggle = self.controls.querySelector('.toggle') + toggle.title = 'Play' + toggle.querySelector('div').innerHTML = icon.play + self.controls.style.opacity = '1' + }) + that.addEventListener("seeking", event => { + //console.log("seeking") + + }) + that.addEventListener("seeked", event => { + //console.log("seeked") + }) + that.addEventListener("volumechange", event => { + var volume = self.controls.querySelector('.volume') + if (that.muted()) { + volume.innerHTML = icon.unmute + volume.title = "Unmute" + } else { + volume.innerHTML = icon.mute + volume.title = "Mute" + } + }) + window.addEventListener('resize', event => { + // + }) + return that +}; + +})(); diff --git a/app/static/js/api.js b/app/static/js/api.js new file mode 100644 index 0000000..5e98959 --- /dev/null +++ b/app/static/js/api.js @@ -0,0 +1,25 @@ +var pandora = { + format: getFormat(), + hostname: document.location.hostname || 'pad.ma' +} + +var pandoraURL = document.location.hostname ? "" : `https://${pandora.hostname}` +var cache = cache || {} + +async function pandoraAPI(action, data) { + var url = pandoraURL + '/api/' + var key = JSON.stringify([action, data]) + if (!cache[key]) { + var response = await fetch(url, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + action: action, + data: data + }) + }) + cache[key] = await response.json() + } + return cache[key] +} + diff --git a/app/static/js/comments.js b/app/static/js/comments.js new file mode 100644 index 0000000..b8b8ebd --- /dev/null +++ b/app/static/js/comments.js @@ -0,0 +1,66 @@ +function renderComments(data) { + var cdiv = div.querySelector('.comments') + cdiv.innerHTML = ` +

+ ${icon.down} + Comments +

+ ` + comments.forEach(comment => { + var c = document.createElement('div') + c.className = 'comment' + c.innerHTML = ` +
+
${comment.name}
+
${comment.date}
+
${comment.text}
+
+ ` + cdiv.append(c) + }) + var add = document.querySelector('.add-comment') + add.style.display = 'block' + cdiv.append(add) +} + +document.querySelector('button#add-comment').addEventListener('click', event => { + var data = {}, csrf; + document.querySelector('.add-comment').querySelectorAll('input,textarea').forEach(input => { + if (input.name == 'csrfmiddlewaretoken') { + csrf = input.value.trim() + } else { + data[input.name] = input.value.trim() + if (!data[input.name]) { + delete data[input.name] + } + } + }) + data.item = pandora.comment + fetch("/comment/", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrf + }, + body: JSON.stringify(data) + }).then(response => { + return response.json() + }).then(response => { + var comment= document.createElement('div') + comment.classList.add('comment') + var name = document.createElement('div') + name.classList.add('name') + name.innerText = response.name + comment.append(name) + var date = document.createElement('div') + date.classList.add('date') + date.innerText = response.date + comment.append(date) + var text = document.createElement('div') + text.classList.add('name') + text.innerText = response.text + comment.append(text) + document.querySelector('.comments').append(comment) + document.querySelector('.add-comment textarea').value = '' + }) +}) diff --git a/app/static/js/documents.js b/app/static/js/documents.js new file mode 100644 index 0000000..2d58b0f --- /dev/null +++ b/app/static/js/documents.js @@ -0,0 +1,112 @@ + +async function loadDocument(id, args) { + var data = window.data = {} + var parts = id.split('/') + data.id = parts.shift() + data.site = pandora.hostname + + if (parts.length == 2) { + data.page = parts.shift() + } + + if (parts.length == 1) { + var rect = parts[0].split(',') + if (rect.length == 1) { + data.page = parts[0] + } else { + data.crop = rect + } + } else if (parts.length == 2) { + + } + + var response = await pandoraAPI('getDocument', { + id: data.id, + keys: [ + "id", + "title", + "extension", + "text", + ] + }) + if (response.status.code != 200) { + return { + site: data.site, + error: response.status + } + } + data.document = response['data'] + data.title = data.document.name + data.link = `${pandora.proto}://${data.site}/documents/${data.document.id}` + return data +} + +async function renderDocument(data) { + if (data.error) { + return renderError(data) + } + div = document.createElement('div') + div.className = "content" + if (!data.document) { + div.innerHTML = `
document not found
` + } else if (data.document.extension == "html") { + div.innerHTML = ` +

${data.document.title}

+
+ ${data.document.text} +
+ + ` + div.querySelectorAll('.text a').forEach(a => { + a.addEventListener("click", clickLink) + + }) + } else if (data.document.extension == "pdf" && data.page && data.crop) { + var img = `${pandora.proto}://${data.site}/documents/${data.document.id}/1024p${data.page},${data.crop.join(',')}.jpg` + data.link = getLink(`documents/${data.document.id}/${data.page}`) + div.innerHTML = ` +

${data.document.title}

+
+ +
+ + ` + } else if (data.document.extension == "pdf") { + var page = data.page || 1, + file = encodeURIComponent(`/documents/${data.document.id}/${safeDocumentName(data.document.title)}.pdf`) + div.innerHTML = ` +

${data.document.title}

+
+ +
+ + ` + } else if (data.document.extension == "jpg" || data.document.extension == "png") { + var img = `${pandora.proto}://${data.site}/documents/${data.document.id}/${safeDocumentName(data.document.title)}.${data.document.extension}` + var open_text = `Open on ${data.site}` + if (data.crop) { + img = `${pandora.proto}://${data.site}/documents/${data.document.id}/1024p${data.crop.join(',')}.jpg` + data.link = getLink(`documents/${data.document.id}`) + open_text = `Open image` + } + div.innerHTML = ` +

${data.document.title}

+
+ +
+ + ` + + } else { + div.innerHTML = `unsupported document type` + } + document.querySelector(".content").replaceWith(div) +} diff --git a/app/static/js/edits.js b/app/static/js/edits.js new file mode 100644 index 0000000..f23766e --- /dev/null +++ b/app/static/js/edits.js @@ -0,0 +1,230 @@ + +const getSortValue = function(value) { + var sortValue = value; + function trim(value) { + return value.replace(/^\W+(?=\w)/, ''); + } + if ( + isEmpty(value) + || isNull(value) + || isUndefined(value) + ) { + sortValue = null; + } else if (isString(value)) { + // make lowercase and remove leading non-word characters + sortValue = trim(value.toLowerCase()); + // move leading articles to the end + // and remove leading non-word characters + ['a', 'an', 'the'].forEach(function(article) { + if (new RegExp('^' + article + ' ').test(sortValue)) { + sortValue = trim(sortValue.slice(article.length + 1)) + + ', ' + sortValue.slice(0, article.length); + return false; // break + } + }); + // remove thousand separators and pad numbers + sortValue = sortValue.replace(/(\d),(?=(\d{3}))/g, '$1') + .replace(/\d+/g, function(match) { + return match.padStart(64, '0') + }); + } + return sortValue; +}; + +const sortByKey = function(array, by) { + return array.sort(function(a, b) { + var aValue, bValue, index = 0, key, ret = 0; + while (ret == 0 && index < by.length) { + key = by[index].key; + aValue = getSortValue(a[key]) + bValue = getSortValue(b[key]) + if ((aValue === null) != (bValue === null)) { + ret = aValue === null ? 1 : -1; + } else if (aValue < bValue) { + ret = by[index].operator == '+' ? -1 : 1; + } else if (aValue > bValue) { + ret = by[index].operator == '+' ? 1 : -1; + } else { + index++; + } + } + return ret; + }); +}; + +async function sortClips(edit, sort) { + var key = sort.key, index; + if (key == 'position') { + key = 'in'; + } + if ([ + 'id', 'index', 'in', 'out', 'duration', + 'title', 'director', 'year', 'videoRatio' + ].indexOf(key) > -1) { + sortBy(sort); + index = 0; + edit.clips.forEach(function(clip) { + clip.sort = index++; + if (sort.operator == '-') { + clip.sort = -clip.sort; + } + }); + } else { + var response = await pandoraAPI('sortClips', { + edit: edit.id, + sort: [sort] + }) + edit.clips.forEach(function(clip) { + clip.sort = response.data.clips.indexOf(clip.id); + if (sort.operator == '-') { + clip.sort = -clip.sort; + } + }); + sortBy({ + key: 'sort', + operator: '+' + }); + } + function sortBy(key) { + edit.clips = sortByKey(edit.clips, [key]); + } +} + +async function loadEdit(id, args) { + var data = window.data = {} + data.id = id + data.site = pandora.hostname + + var response = await pandoraAPI('getEdit', { + id: data.id, + keys: [ + ] + }) + if (response.status.code != 200) { + return { + site: data.site, + error: response.status + } + } + data.edit = response['data'] + if (data.edit.status !== 'public') { + return { + site: data.site, + error: { + code: 403, + text: 'permission denied' + } + } + } + data.layers = {} + data.videos = [] + + if (args.sort) { + await sortClips(data.edit, args.sort) + } + + data.edit.duration = 0; + data.edit.clips.forEach(function(clip) { + clip.position = data.edit.duration; + data.edit.duration += clip.duration; + }); + + data.edit.clips.forEach(clip => { + var start = clip['in'] || 0, end = clip.out, position = 0; + clip.durations.forEach((duration, idx) => { + if (!duration) { + return + } + if (position + duration <= start || position > end) { + // pass + } else { + var video = {} + var oshash = clip.streams[idx] + video.src = getVideoURL(clip.item, 480, idx+1, '', oshash) + /* + if (clip['in'] && clip.out) { + video.src += `#t=${clip['in']},${clip.out}` + } + */ + if (isNumber(clip.volume)) { + video.volume = clip.volume; + } + if ( + position <= start + && position + duration > start + ) { + video['in'] = start - position; + } + if (position + duration >= end) { + video.out = end - position; + } + if (video['in'] && video.out) { + video.duration = video.out - video['in'] + } else if (video.out) { + video.duration = video.out; + } else if (!isUndefined(video['in'])) { + video.duration = duration - video['in']; + video.out = duration; + } else { + video.duration = duration; + video['in'] = 0; + video.out = video.duration; + } + data.videos.push(video) + } + position += duration + }) + Object.keys(clip.layers).forEach(layer => { + data.layers[layer] = data.layers[layer] || [] + clip.layers[layer].forEach(annotation => { + if (args.users && !args.users.includes(annotation.user)) { + return + } + if (args.layers && !args.layers.includes(layer)) { + return + } + var a = {...annotation} + a['id'] = clip['id'] + '/' + a['id']; + a['in'] = Math.max( + clip['position'], + a['in'] - clip['in'] + clip['position'] + ); + a.out = Math.min( + clip['position'] + clip['duration'], + a.out - clip['in'] + clip['position'] + ); + data.layers[layer].push(a) + }) + }) + }) + var value = [] + pandora.layerKeys.forEach(layer => { + if (!data.layers[layer]) { + return + } + var html = [] + var layerData = getObjectById(pandora.site.layers, layer) + html.push(`

+ ${icon.down} + ${layerData.title} +

`) + data.layers[layer].forEach(annotation => { + html.push(` +
+ ${annotation.value} +
+ `) + }) + value.push('
' + html.join('\n') + '
') + }) + data.value = value.join('\n') + + data.title = data.edit.name + data.byline = data.edit.description + data.link = `${pandora.proto}://${data.site}/edits/${data.edit.id}` + data.poster = data.videos[0].src.split('/48')[0] + `/480p${data.videos[0].in}.jpg` + data.aspectratio = data.edit.clips[0].videoRatio + data.duration = data.edit.duration + return data + +} diff --git a/app/static/js/icons.js b/app/static/js/icons.js new file mode 100644 index 0000000..295a12f --- /dev/null +++ b/app/static/js/icons.js @@ -0,0 +1,182 @@ +var icon = {} +icon.enterFullscreen = ` + + + + + + + + +` +icon.exitFullscreen = ` + + + + + + + + +` + +icon.mute = ` + + + + + + + +` + +icon.unmute = ` + + + + +` + +icon.play = ` + + + +` +icon.pause = ` + + + + + +` +icon.loading = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +icon.right = ` + + + +` +icon.left = ` + + + +` +icon.down = ` + + + +` diff --git a/app/static/js/item.js b/app/static/js/item.js new file mode 100644 index 0000000..61463aa --- /dev/null +++ b/app/static/js/item.js @@ -0,0 +1,133 @@ + +async function loadData(id, args) { + var data = window.data = {} + data.id = id + data.site = pandora.hostname + + var response = await pandoraAPI('get', { + id: data.id.split('/')[0], + keys: [ + "id", + "title", + "director", + "summary", + "streams", + "duration", + "durations", + "layers", + "rightslevel", + "videoRatio" + ] + }) + + if (response.status.code != 200) { + return { + site: data.site, + error: response.status + } + } + data.item = response['data'] + if (data.item.rightslevel > pandora.site.capabilities.canPlayClips['guest']) { + return { + site: data.site, + error: { + code: 403, + text: 'permission denied' + } + } + } + if (id.split('/').length == 1 || id.split('/')[1] == 'info') { + data.view = 'info' + data.title = data.item.title + data.byline = data.item.director ? data.item.director.join(', ') : '' + data.link = `${pandora.proto}://${data.site}/${data.item.id}/info` + let poster = pandora.site.user.ui.icons == 'posters' ? 'poster' : 'icon' + data.icon = `${pandora.proto}://${data.site}/${data.item.id}/${poster}.jpg` + return data + } + + if (id.includes('-') || id.includes(',')) { + var inout = id.split('/')[1].split(id.includes('-') ? '-' : ',').map(parseDuration) + data.out = inout.pop() + data['in'] = inout.pop() + } else if (args.full) { + data.out = data.item.duration + data['in'] = 0 + } else { + var annotation = await pandoraAPI('getAnnotation', { + id: data.id, + }) + if (annotation.status.code != 200) { + return { + site: data.site, + error: annotation.status + } + } + data.annotation = annotation['data'] + data['in'] = data.annotation['in'] + data.out = data.annotation['out'] + } + + data.layers = {} + + pandora.layerKeys.forEach(layer => { + data.item.layers[layer].forEach(annot => { + if (data.annotation) { + if (annot.id == data.annotation.id) { + data.layers[layer] = data.layers[layer] || [] + data["layers"][layer].push(annot) + } + } else if (annot['out'] > data['in'] && annot['in'] < data['out']) { + if (args.users && !args.users.includes(annot.user)) { + return + } + if (args.layers && !args.layers.includes(layer)) { + return + } + data.layers[layer] = data.layers[layer] || [] + //annot['in'] = Math.max([annot['in'], data['in']]) + //annot['out'] = Math.min([annot['out'], data['out']]) + data["layers"][layer].push(annot) + } + }) + }) + data.videos = [] + data.item.durations.forEach((duration, idx) => { + var oshash = data.item.streams[idx] + var url = getVideoURL(data.item.id, 480, idx+1, '', oshash) + data.videos.push({ + src: url, + duration: duration + }) + }) + var value = [] + Object.keys(data.layers).forEach(layer => { + var html = [] + var layerData = getObjectById(pandora.site.layers, layer) + html.push(`

+ ${icon.down} + ${layerData.title} +

`) + data.layers[layer].forEach(annotation => { + html.push(` +
+ ${annotation.value} +
+ `) + }) + value.push('
' + html.join('\n') + '
') + }) + data.value = value.join('\n') + + data.title = data.item.title + data.byline = data.item.director ? data.item.director.join(', ') : '' + data.link = `${pandora.proto}://${data.site}/${data.item.id}/${data["in"]},${data.out}` + data.poster = `${pandora.proto}://${data.site}/${data.item.id}/480p${data["in"]}.jpg` + data.aspectratio = data.item.videoRatio + if (data['in'] == data['out']) { + data['out'] += 0.04 + } + data.duration = data.out - data['in'] + return data +} + diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..a9b368b --- /dev/null +++ b/app/static/js/main.js @@ -0,0 +1,130 @@ + + +function parseURL() { + var url = pandora.url ? pandora.url : document.location, + fragment = url.hash.slice(1) + if (!fragment && url.pathname.startsWith('/m/')) { + var prefix = url.protocol + '//' + url.hostname + '/m/' + fragment = url.href.slice(prefix.length) + } + var args = fragment.split('?') + var id = args.shift() + if (args) { + args = args.join('?').split('&').map(arg => { + var kv = arg.split('=') + k = kv.shift() + v = kv.join('=') + if (['users', 'layers'].includes(k)) { + v = v.split(',') + } + return [k, v] + }).filter(kv => { + return kv[0].length + }) + if (args) { + args = Object.fromEntries(args); + } else { + args = {} + } + } else { + args = {} + } + var type = "item" + if (id.startsWith('document')) { + id = id.split('/') + id.shift() + id = id.join('/') + type = "document" + } else if (id.startsWith('edits/')) { + var parts = id.split('/') + parts.shift() + id = parts.shift().replace(/_/g, ' ') + type = "edit" + if (parts.length >= 2) { + args.sort = parts[1] + if (args.sort[0] == '-') { + args.sort = { + key: args.sort.slice(1), + operator: '-' + } + } else if (args.sort[0] == '+') { + args.sort = { + key: args.sort.slice(1), + operator: '+' + } + } else { + args.sort = { + key: args.sort, + operator: '+' + } + } + } + args.parts = parts + } else { + if (id.endsWith('/player') || id.endsWith('/editor')) { + args.full = true + } + id = id.replace('/editor/', '/').replace('/player/', '/') + type = "item" + } + return [type, id, args] +} + +function render() { + var type, id, args; + [type, id, args] = parseURL() + document.querySelector(".content").innerHTML = loadingScreen + if (type == "document") { + loadDocument(id, args).then(renderDocument) + } else if (type == "edit") { + loadEdit(id, args).then(renderItem) + } else { + loadData(id, args).then(renderItem) + } + +} +var loadingScreen = ` + +
${icon.loading}
+` + +document.querySelector(".content").innerHTML = loadingScreen +pandoraAPI("init").then(response => { + pandora = { + ...pandora, + ...response.data + } + pandora.proto = pandora.site.site.https ? 'https' : 'http' + if (pandora.site.site.videoprefix.startsWith('//')) { + pandora.site.site.videoprefix = pandora.proto + ':' + pandora.site.site.videoprefix + } + var layerKeys = [] + var subtitleLayer = pandora.site.layers.filter(layer => {return layer.isSubtitles})[0] + if (subtitleLayer) { + layerKeys.push(subtitleLayer.id) + } + pandora.site.layers.map(layer => { + return layer.id + }).filter(layer => { + return !subtitleLayer || layer != subtitleLayer.id + }).forEach(layer => { + layerKeys.push(layer) + }) + pandora.layerKeys = layerKeys + id = document.location.hash.slice(1) + window.addEventListener("hashchange", event => { + render() + }) + window.addEventListener("popstate", event => { + console.log("popsatte") + render() + }) + window.addEventListener('resize', event => { + }) + render() +}) diff --git a/app/static/js/overwrite.js b/app/static/js/overwrite.js new file mode 100644 index 0000000..e69de29 diff --git a/app/static/js/render.js b/app/static/js/render.js new file mode 100644 index 0000000..d9724da --- /dev/null +++ b/app/static/js/render.js @@ -0,0 +1,121 @@ + +function renderItemInfo(data) { + div = document.createElement('div') + div.className = "content" + div.innerHTML = ` +
${data.title}
+ +
+ +
+ + ` + document.querySelector(".content").replaceWith(div) +} + +function renderItem(data) { + if (data.error) { + return renderError(data) + } + if (data.view == "info") { + return renderItemInfo(data) + } + div = document.createElement('div') + div.className = "content" + div.innerHTML = ` +
${data.title}
+ +
+
+
+
${data.value}
+
+ + ` + + var comments = div.querySelector('.comments') + if (window.renderComments) { + renderComments(comments, data) + } else { + comments.remove() + } + + div.querySelectorAll('.layer a').forEach(a => { + a.addEventListener("click", clickLink) + }) + + div.querySelectorAll('.layer').forEach(layer => { + layer.querySelector('h3').addEventListener("click", event => { + var img = layer.querySelector('h3 .icon') + if (layer.classList.contains("collapsed")) { + layer.classList.remove("collapsed") + img.innerHTML = icon.down + } else { + layer.classList.add("collapsed") + img.innerHTML = icon.right + } + }) + }) + + var video = window.video = VideoPlayer({ + items: data.videos, + poster: data.poster, + position: data["in"] || 0, + duration: data.duration, + aspectratio: data.aspectratio + }) + div.querySelector('.video').replaceWith(video) + video.classList.add('video') + + video.addEventListener("loadedmetadata", event => { + // + }) + video.addEventListener("timeupdate", event => { + var currentTime = video.currentTime() + if (currentTime >= data['out']) { + if (!video.paused) { + video.pause() + } + video.currentTime(data['in']) + } + div.querySelectorAll('.annotation').forEach(annot => { + var now = currentTime + var start = parseFloat(annot.dataset.in) + var end = parseFloat(annot.dataset.out) + if (now >= start && now <= end) { + annot.classList.add("active") + annot.parentElement.classList.add('active') + } else { + annot.classList.remove("active") + if (!annot.parentElement.querySelector('.active')) { + annot.parentElement.classList.remove('active') + } + } + }) + + }) + document.querySelector(".content").replaceWith(div) +} + +function renderError(data) { + var link = '/' + document.location.hash.slice(1) + div = document.createElement('div') + div.className = "content" + div.innerHTML = ` + +
+ Page not found
+ Open on ${data.site} +
+ ` + document.querySelector(".content").replaceWith(div) +} diff --git a/app/static/js/utils.js b/app/static/js/utils.js new file mode 100644 index 0000000..d77024f --- /dev/null +++ b/app/static/js/utils.js @@ -0,0 +1,160 @@ + +const parseDuration = function(string) { + return string.split(':').reverse().slice(0, 4).reduce(function(p, c, i) { + return p + (parseFloat(c) || 0) * (i == 3 ? 86400 : Math.pow(60, i)); + }, 0); +}; + +const formatDuration = function(seconds) { + var parts = [ + parseInt(seconds / 86400), + parseInt(seconds % 86400 / 3600), + parseInt(seconds % 3600 / 60), + s = parseInt(seconds % 60) + ] + return parts.map(p => { return p.toString().padStart(2, '0')}).join(':') +} + +const typeOf = function(value) { + return Object.prototype.toString.call(value).slice(8, -1).toLowerCase(); +}; +const isUndefined = function(value) { + return typeOf(value) == 'undefined'; +} +const isNumber = function(value) { + return typeOf(value) == 'number'; +}; +const isObject = function(value) { + return typeOf(value) == 'object'; +}; +const isNull = function(value) { + return typeOf(value) == 'null'; +}; +const isString = function(value) { + return typeOf(value) == 'string'; +}; +const isEmpty = function(value) { + var type = typeOf(value) + if (['arguments', 'array', 'nodelist', 'string'].includes(value)) { + return value.length == 0 + } + if (['object', 'storage'].includes(type)) { + return Object.keys(value).length; + } + return false +}; +const mod = function(number, by) { + return (number % by + by) % by; +}; + +const getObjectById = function(array, id) { + return array.filter(obj => { return obj.id == id})[0] +} + +const debug = function() { + if (localStorage.debug) { + console.log.apply(null, arguments) + } +}; + +const canPlayMP4 = function() { + var video = document.createElement('video'); + if (video.canPlayType && video.canPlayType('video/mp4; codecs="avc1.42E01E, mp4a.40.2"').replace('no', '')) { + return true + } + return false +}; + +const canPlayWebm = function() { + var video = document.createElement('video'); + if (video.canPlayType && video.canPlayType('video/webm; codecs="vp8, vorbis"').replace('no', '')) { + return true + } + return false +}; + +const getFormat = function() { + //var format = canPlayWebm() ? "webm" : "mp4" + var format = canPlayMP4() ? "mp4" : "webm" + return format +} + +const safeDocumentName = function(name) { + ['\\?', '#', '%', '/'].forEach(function(c) { + var r = new RegExp(c, 'g') + name = name.replace(r, '_'); + }) + return name; +}; + +const getVideoInfo = function() { + console.log("FIXME implement getvideoInfo") +} + +const isIOS = /(iPad|iPhone|iPod)/g.test(navigator.userAgent); + + +const getLink = function(fragment) { + if (document.location.hash.length > 2) { + return '#' + fragment + } else { + return '/m/' + fragment + } +} + +const clickLink = function(event) { + var a = event.target + while (a && a.tagName != 'A') { + a = a.parentElement + } + if (!a) { + return + } + var href = a.attributes.href.value + var prefix = document.location.protocol + '//' + document.location.hostname + if (href.startsWith(prefix)) { + href = href.slice(prefix.length) + } + if (href.startsWith('/')) { + event.preventDefault() + event.stopPropagation() + var link = href.split('#embed')[0] + if (document.location.hash.length > 2) { + if (link.startsWith('/m')) { + link = link.slice(2) + } + document.location.hash = '#' + link.slice(1) + } else { + if (!link.startsWith('/m')) { + link = '/m' + link + } + history.pushState({}, '', link); + render() + } + } +} + +const getUid = (function() { + var uid = 0; + return function() { + return ++uid; + }; +}()); + + +const getVideoURLName = function(id, resolution, part, track, streamId) { + return id + '/' + resolution + 'p' + part + (track ? '.' + track : '') + + '.' + pandora.format + (streamId ? '?' + streamId : ''); +}; + +const getVideoURL = function(id, resolution, part, track, streamId) { + var uid = getUid(), + prefix = pandora.site.site.videoprefix + .replace('{id}', id) + .replace('{part}', part) + .replace('{resolution}', resolution) + .replace('{uid}', uid) + .replace('{uid42}', uid % 42); + return prefix + '/' + getVideoURLName(id, resolution, part, track, streamId); +}; + diff --git a/app/templates/:u b/app/templates/:u new file mode 100644 index 0000000..4558b40 --- /dev/null +++ b/app/templates/:u @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block head %} + + +{% endblock %} +{% block content %} + + +
+ +
+
+ {% for comment in item.public_comments %} +
+
{{ comment.name }}
+
{{ comment.date }}
+
{{ comment.text }}
+
+ {% endfor %} +
+
+ {% if request.user.is_anonymous %} + + +
+ {% endif %} + {% csrf_token %} + + +
+{% endblock %} +{% block end %} + + + + + + + + + + + + +{% endblock %} diff --git a/app/templates/archive.html b/app/templates/archive.html new file mode 100644 index 0000000..feaeb05 --- /dev/null +++ b/app/templates/archive.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} + {% for item in items %} + + {% endfor %} +{% endblock %} + diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..6a8eecf --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,15 @@ + + + +{% block head %} +{% endblock %} +{% block main %} +
+ {% block content %} + {% endblock %} +
+{% endblock %} +{% block end %} +{% endblock %} + + diff --git a/app/templates/index.html b/app/templates/index.html new file mode 100644 index 0000000..2a84337 --- /dev/null +++ b/app/templates/index.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block content %} + + {% for item in items %} + + {% endfor %} + {% if archive %} + older items + {% endif %} +{% endblock %} diff --git a/app/templates/item.html b/app/templates/item.html new file mode 100644 index 0000000..4042753 --- /dev/null +++ b/app/templates/item.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block head %} + + + +{% endblock %} +{% block main %} +
+
+ +{% endblock %} +{% block end %} + + + + + + + + + + + + + + + + +{% endblock %} diff --git a/app/templates/item2.html b/app/templates/item2.html new file mode 100644 index 0000000..f293282 --- /dev/null +++ b/app/templates/item2.html @@ -0,0 +1,16 @@ + + + +
+ + + + + + + + + + + + diff --git a/app/urls.py b/app/urls.py new file mode 100644 index 0000000..5f1f4eb --- /dev/null +++ b/app/urls.py @@ -0,0 +1,28 @@ +""" +URL configuration for app project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, re_path + +from .item import views as item_views + +urlpatterns = [ + path("admin/", admin.site.urls), + path('archive/', item_views.archive, name='archive'), + path('comment/', item_views.comment, name='comment'), + path('/', item_views.item, name='item'), + path('', item_views.index, name='index'), +] diff --git a/app/user/__init__.py b/app/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/admin.py b/app/user/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/app/user/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/app/user/apps.py b/app/user/apps.py new file mode 100644 index 0000000..088a1bc --- /dev/null +++ b/app/user/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UserConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "app.user" diff --git a/app/user/migrations/0001_initial.py b/app/user/migrations/0001_initial.py new file mode 100644 index 0000000..e0366fe --- /dev/null +++ b/app/user/migrations/0001_initial.py @@ -0,0 +1,133 @@ +# Generated by Django 4.2.3 on 2023-07-11 16:06 + +import django.contrib.auth.models +import django.contrib.auth.validators +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "username", + models.CharField( + error_messages={ + "unique": "A user with that username already exists." + }, + help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.", + max_length=150, + unique=True, + validators=[ + django.contrib.auth.validators.UnicodeUsernameValidator() + ], + verbose_name="username", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "email", + models.EmailField( + blank=True, max_length=254, verbose_name="email address" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined" + ), + ), + ("modified", models.DateTimeField(auto_now=True)), + ("data", models.JSONField(default=dict)), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/app/user/migrations/__init__.py b/app/user/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/user/models.py b/app/user/models.py new file mode 100644 index 0000000..6e328ca --- /dev/null +++ b/app/user/models.py @@ -0,0 +1,7 @@ +from django.db import models +from django.contrib.auth.models import AbstractUser + + +class User(AbstractUser): + modified = models.DateTimeField(auto_now=True) + data = models.JSONField(default=dict) diff --git a/app/user/tests.py b/app/user/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/app/user/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/app/user/views.py b/app/user/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/app/user/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/app/wsgi.py b/app/wsgi.py new file mode 100644 index 0000000..cbdf434 --- /dev/null +++ b/app/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for app 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.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..1a64b14 --- /dev/null +++ b/manage.py @@ -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", "app.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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d3e4ba5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +django