7 Commits

Author SHA1 Message Date
8e0a18d707 Update theme icon based on the selected one 2023-09-18 21:39:15 +02:00
46a2aa7011 Bump version 2023-09-18 20:23:03 +02:00
a176682615 Improve header html code 2023-09-18 20:21:41 +02:00
ad4591da04 Bootstrap 5.3 (#21)
* Migarte to Bootstrap 5.3
* Cleanup and version bump
2023-09-18 14:24:27 +02:00
2f2b96b2bb Add requirements-prod.txt 2023-05-07 14:52:49 +02:00
6cf3ad03cc Net-to-serial broadcast messages to all clients and other cleanups (#20)
* Cleanup and maintenance

* Net-to-serial broadcast messages to all clients

This will make all clients to stay in sync with any operation
occurring, like when having multiple JMRI instances

* Update README and python version in containers
2023-03-06 18:25:34 +01:00
2c5f0dcd6f Use slugs to filter 2023-01-09 22:54:15 +01:00
28 changed files with 493 additions and 198 deletions

View File

@@ -96,7 +96,8 @@ Browse to `http://localhost:8000`
The DCC++ EX connector exposes an Arduino board running DCC++ EX Command Station, The DCC++ EX connector exposes an Arduino board running DCC++ EX Command Station,
connected via serial port, to the network, allowing commands to be sent via a connected via serial port, to the network, allowing commands to be sent via a
TCP socket. TCP socket. A response generated by the DCC++ EX board is sent to all connected clients,
providing synchronization between multiple clients (eg. multiple JMRI instances).
Its use is not needed when running DCC++ EX from a [WiFi](https://dcc-ex.com/get-started/wifi-setup.html) capable board (like when Its use is not needed when running DCC++ EX from a [WiFi](https://dcc-ex.com/get-started/wifi-setup.html) capable board (like when
using an ESP8266 module or a [Mega+WiFi board](https://dcc-ex.com/advanced-setup/supported-microcontrollers/wifi-mega.html)). using an ESP8266 module or a [Mega+WiFi board](https://dcc-ex.com/advanced-setup/supported-microcontrollers/wifi-mega.html)).
@@ -112,7 +113,7 @@ Settings may need to be customized based on your setup.
```bash ```bash
$ cd daemons $ cd daemons
$ podman build -t dcc/net-to-serial . $ podman build -t dcc/net-to-serial .
$ podman run -d -p 2560:2560 dcc/net-to-serial $ podman run --group-add keep-groups --device /dev/ttyACM0 -p 2560:2560 dcc/net-to-serial
``` ```
### Manual setup ### Manual setup

View File

@@ -1,4 +1,4 @@
FROM python:3.10-alpine FROM python:3.11-alpine
RUN mkdir /opt/dcc && pip -q install pyserial RUN mkdir /opt/dcc && pip -q install pyserial
ADD net-to-serial.py config.ini /opt/dcc ADD net-to-serial.py config.ini /opt/dcc

3
daemons/README.md Normal file
View File

@@ -0,0 +1,3 @@
## DCC++ EX connector
See [README.md](../README.md)

View File

@@ -2,6 +2,7 @@
LogLevel = debug LogLevel = debug
ListeningIP = 0.0.0.0 ListeningIP = 0.0.0.0
ListeningPort = 2560 ListeningPort = 2560
MaxClients = 10
[Serial] [Serial]
# UNO # UNO

View File

@@ -1,6 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import re import re
import time
import logging import logging
import serial import serial
import asyncio import asyncio
@@ -10,11 +9,15 @@ from pathlib import Path
class SerialDaemon: class SerialDaemon:
connected_clients = set()
def __init__(self, config): def __init__(self, config):
self.ser = serial.Serial( self.ser = serial.Serial(
config["Serial"]["Port"], config["Serial"]["Port"],
timeout=int(config["Serial"]["Timeout"])/1000) timeout=int(config["Serial"]["Timeout"]) / 1000,
)
self.ser.baudrate = config["Serial"]["Baudrate"] self.ser.baudrate = config["Serial"]["Baudrate"]
self.max_clients = int(config["Daemon"]["MaxClients"])
def __del__(self): def __del__(self):
try: try:
@@ -43,19 +46,32 @@ class SerialDaemon:
async def handle_echo(self, reader, writer): async def handle_echo(self, reader, writer):
"""Process a request from socket and return the response""" """Process a request from socket and return the response"""
while 1: # keep connection to client open logging.info(
"Clients already connected: {} (max: {})".format(
len(self.connected_clients),
self.max_clients,
)
)
addr = writer.get_extra_info("peername")[0]
if len(self.connected_clients) < self.max_clients:
self.connected_clients.add(writer)
while True: # keep connection to client open
data = await reader.read(100) data = await reader.read(100)
if not data: # client has disconnected if not data: # client has disconnected
break break
logging.info("Received {} from {}".format(data, addr))
addr = writer.get_extra_info('peername')
logging.info("Received {} from {}".format(data, addr[0]))
self.__write_serial(data) self.__write_serial(data)
response = self.__read_serial() response = self.__read_serial()
writer.write(response) for client in self.connected_clients:
await writer.drain() client.write(response)
await client.drain()
logging.info("Sent: {}".format(response)) logging.info("Sent: {}".format(response))
self.connected_clients.remove(writer)
else:
logging.warning(
"TooManyClients: client {} disconnected".format(addr)
)
writer.close() writer.close()
await writer.wait_closed() await writer.wait_closed()
@@ -68,33 +84,37 @@ class SerialDaemon:
while "DCC-EX" not in line: while "DCC-EX" not in line:
line = self.__read_serial().decode() line = self.__read_serial().decode()
board = re.findall(r"<iDCC-EX.*>", line)[0] board = re.findall(r"<iDCC-EX.*>", line)[0]
return(board) return board
async def main(): async def main():
config = configparser.ConfigParser() config = configparser.ConfigParser()
config.read( config.read(
Path(__file__).resolve().parent / "config.ini") # mimick os.path.join Path(__file__).resolve().parent / "config.ini"
) # mimick os.path.join
logging.basicConfig(level=config["Daemon"]["LogLevel"].upper()) logging.basicConfig(level=config["Daemon"]["LogLevel"].upper())
sd = SerialDaemon(config) sd = SerialDaemon(config)
server = await asyncio.start_server( server = await asyncio.start_server(
sd.handle_echo, sd.handle_echo,
config["Daemon"]["ListeningIP"], config["Daemon"]["ListeningIP"],
config["Daemon"]["ListeningPort"]) config["Daemon"]["ListeningPort"],
)
addr = server.sockets[0].getsockname() addr = server.sockets[0].getsockname()
logging.warning("Serving on {} port {}".format(addr[0], addr[1])) logging.info("Serving on {} port {}".format(addr[0], addr[1]))
logging.warning( logging.info(
"Proxying to {} (Baudrate: {}, Timeout: {})".format( "Proxying to {} (Baudrate: {}, Timeout: {})".format(
config["Serial"]["Port"], config["Serial"]["Port"],
config["Serial"]["Baudrate"], config["Serial"]["Baudrate"],
config["Serial"]["Timeout"])) config["Serial"]["Timeout"],
logging.warning("Initializing board") )
logging.warning("Board {} ready".format( )
await sd.return_board())) logging.info("Initializing board")
logging.info("Board {} ready".format(await sd.return_board()))
async with server: async with server:
await server.serve_forever() await server.serve_forever()
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

Binary file not shown.

View File

@@ -0,0 +1,93 @@
# Generated by Django 4.1.5 on 2023-01-09 11:22
from django.db import migrations, models
from ram.utils import slugify
def create_slug(apps, schema_editor):
fields = ["Company", "Manufacturer", "RollingStockType", "Scale"]
for m in fields:
model = apps.get_model("metadata", m)
for row in model.objects.all():
if hasattr(row, "type"):
row.slug = slugify("{} {}".format(row.type, row.category))
elif hasattr(row, "scale"):
row.slug = slugify(row.scale)
else:
row.slug = slugify(row.name)
row.save(update_fields=["slug"])
class Migration(migrations.Migration):
dependencies = [
("metadata", "0010_alter_manufacturer_category"),
]
operations = [
migrations.AddField(
model_name="company",
name="slug",
field=models.CharField(
editable=False, max_length=64, blank=True
),
),
migrations.AddField(
model_name="manufacturer",
name="slug",
field=models.CharField(
editable=False, max_length=128, blank=True
),
),
migrations.AddField(
model_name="rollingstocktype",
name="slug",
field=models.CharField(
editable=False, max_length=128, blank=True
),
),
migrations.AddField(
model_name="scale",
name="slug",
field=models.CharField(
editable=False, max_length=32, blank=True
),
),
migrations.RunPython(
create_slug,
reverse_code=migrations.RunPython.noop
),
migrations.AlterField(
model_name="company",
name="slug",
field=models.CharField(
editable=False, max_length=64, unique=True
),
),
migrations.AlterField(
model_name="manufacturer",
name="slug",
field=models.CharField(
editable=False, max_length=128, unique=True
),
),
migrations.AlterField(
model_name="rollingstocktype",
name="slug",
field=models.CharField(
editable=False, max_length=128, unique=True
),
),
migrations.AlterField(
model_name="scale",
name="slug",
field=models.CharField(
editable=False, max_length=32, unique=True
),
),
]

View File

@@ -1,6 +1,7 @@
from urllib.parse import quote from urllib.parse import quote
from django.db import models from django.db import models
from django.urls import reverse
from django.conf import settings from django.conf import settings
from django.dispatch.dispatcher import receiver from django.dispatch.dispatcher import receiver
from django_countries.fields import CountryField from django_countries.fields import CountryField
@@ -22,6 +23,7 @@ class Property(models.Model):
class Manufacturer(models.Model): class Manufacturer(models.Model):
name = models.CharField(max_length=128, unique=True) name = models.CharField(max_length=128, unique=True)
slug = models.CharField(max_length=128, unique=True, editable=False)
category = models.CharField( category = models.CharField(
max_length=64, choices=settings.MANUFACTURER_TYPES max_length=64, choices=settings.MANUFACTURER_TYPES
) )
@@ -36,8 +38,13 @@ class Manufacturer(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def safe_name(self): def get_absolute_url(self):
return quote(self.__str__().lower(), safe="& ") return reverse(
"filtered", kwargs={
"_filter": "manufacturer",
"search": self.slug,
}
)
def logo_thumbnail(self): def logo_thumbnail(self):
return get_image_preview(self.logo.url) return get_image_preview(self.logo.url)
@@ -47,6 +54,7 @@ class Manufacturer(models.Model):
class Company(models.Model): class Company(models.Model):
name = models.CharField(max_length=64, unique=True) name = models.CharField(max_length=64, unique=True)
slug = models.CharField(max_length=64, unique=True, editable=False)
extended_name = models.CharField(max_length=128, blank=True) extended_name = models.CharField(max_length=128, blank=True)
country = CountryField() country = CountryField()
freelance = models.BooleanField(default=False) freelance = models.BooleanField(default=False)
@@ -61,8 +69,13 @@ class Company(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def safe_name(self): def get_absolute_url(self):
return quote(self.__str__().lower(), safe="& ") return reverse(
"filtered", kwargs={
"_filter": "company",
"search": self.slug,
}
)
def logo_thumbnail(self): def logo_thumbnail(self):
return get_image_preview(self.logo.url) return get_image_preview(self.logo.url)
@@ -94,6 +107,7 @@ class Decoder(models.Model):
class Scale(models.Model): class Scale(models.Model):
scale = models.CharField(max_length=32, unique=True) scale = models.CharField(max_length=32, unique=True)
slug = models.CharField(max_length=32, unique=True, editable=False)
ratio = models.CharField(max_length=16, blank=True) ratio = models.CharField(max_length=16, blank=True)
gauge = models.CharField(max_length=16, blank=True) gauge = models.CharField(max_length=16, blank=True)
tracks = models.CharField(max_length=16, blank=True) tracks = models.CharField(max_length=16, blank=True)
@@ -101,11 +115,40 @@ class Scale(models.Model):
class Meta: class Meta:
ordering = ["scale"] ordering = ["scale"]
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"_filter": "scale",
"search": self.slug,
}
)
def __str__(self): def __str__(self):
return str(self.scale) return str(self.scale)
def safe_name(self):
return quote(self.__str__(), safe="& ") class RollingStockType(models.Model):
type = models.CharField(max_length=64)
order = models.PositiveSmallIntegerField()
category = models.CharField(
max_length=64, choices=settings.ROLLING_STOCK_TYPES
)
slug = models.CharField(max_length=128, unique=True, editable=False)
class Meta(object):
unique_together = ("category", "type")
ordering = ["order"]
def get_absolute_url(self):
return reverse(
"filtered", kwargs={
"_filter": "type",
"search": self.slug,
}
)
def __str__(self):
return "{0} {1}".format(self.type, self.category)
class Tag(models.Model): class Tag(models.Model):
@@ -115,28 +158,20 @@ class Tag(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def safe_name(self): def get_absolute_url(self):
return self.slug return reverse(
"filtered", kwargs={
"_filter": "tag",
@receiver(models.signals.pre_save, sender=Tag) "search": self.slug,
def slug_pre_save(sender, instance, **kwargs): }
instance.slug = slugify(instance.name)
class RollingStockType(models.Model):
type = models.CharField(max_length=64)
order = models.PositiveSmallIntegerField()
category = models.CharField(
max_length=64, choices=settings.ROLLING_STOCK_TYPES
) )
class Meta(object):
unique_together = ("category", "type")
ordering = ["order"]
def __str__(self):
return "{0} {1}".format(self.type, self.category)
def safe_name(self): @receiver(models.signals.pre_save, sender=Manufacturer)
return quote(self.__str__().lower(), safe="& ") @receiver(models.signals.pre_save, sender=Company)
@receiver(models.signals.pre_save, sender=Scale)
@receiver(models.signals.pre_save, sender=RollingStockType)
@receiver(models.signals.pre_save, sender=Tag)
def slug_pre_save(sender, instance, **kwargs):
instance.slug = slugify(instance.__str__())

View File

@@ -1,8 +1,14 @@
/*!
* Bootstrap Icons v1.11.1 (https://icons.getbootstrap.com/)
* Copyright 2019-2023 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE)
*/
@font-face { @font-face {
font-display: block; font-display: block;
font-family: "bootstrap-icons"; font-family: "bootstrap-icons";
src: url("./fonts/bootstrap-icons.woff2?24e3eb84d0bcaf83d77f904c78ac1f47") format("woff2"), src: url("./fonts/bootstrap-icons.woff2?2820a3852bdb9a5832199cc61cec4e65") format("woff2"),
url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("woff"); url("./fonts/bootstrap-icons.woff?2820a3852bdb9a5832199cc61cec4e65") format("woff");
} }
.bi::before, .bi::before,
@@ -441,7 +447,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-cloud-fog2::before { content: "\f2a2"; } .bi-cloud-fog2::before { content: "\f2a2"; }
.bi-cloud-hail-fill::before { content: "\f2a3"; } .bi-cloud-hail-fill::before { content: "\f2a3"; }
.bi-cloud-hail::before { content: "\f2a4"; } .bi-cloud-hail::before { content: "\f2a4"; }
.bi-cloud-haze-1::before { content: "\f2a5"; }
.bi-cloud-haze-fill::before { content: "\f2a6"; } .bi-cloud-haze-fill::before { content: "\f2a6"; }
.bi-cloud-haze::before { content: "\f2a7"; } .bi-cloud-haze::before { content: "\f2a7"; }
.bi-cloud-haze2-fill::before { content: "\f2a8"; } .bi-cloud-haze2-fill::before { content: "\f2a8"; }
@@ -1437,21 +1442,16 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-dpad::before { content: "\f687"; } .bi-dpad::before { content: "\f687"; }
.bi-ear-fill::before { content: "\f688"; } .bi-ear-fill::before { content: "\f688"; }
.bi-ear::before { content: "\f689"; } .bi-ear::before { content: "\f689"; }
.bi-envelope-check-1::before { content: "\f68a"; }
.bi-envelope-check-fill::before { content: "\f68b"; } .bi-envelope-check-fill::before { content: "\f68b"; }
.bi-envelope-check::before { content: "\f68c"; } .bi-envelope-check::before { content: "\f68c"; }
.bi-envelope-dash-1::before { content: "\f68d"; }
.bi-envelope-dash-fill::before { content: "\f68e"; } .bi-envelope-dash-fill::before { content: "\f68e"; }
.bi-envelope-dash::before { content: "\f68f"; } .bi-envelope-dash::before { content: "\f68f"; }
.bi-envelope-exclamation-1::before { content: "\f690"; }
.bi-envelope-exclamation-fill::before { content: "\f691"; } .bi-envelope-exclamation-fill::before { content: "\f691"; }
.bi-envelope-exclamation::before { content: "\f692"; } .bi-envelope-exclamation::before { content: "\f692"; }
.bi-envelope-plus-fill::before { content: "\f693"; } .bi-envelope-plus-fill::before { content: "\f693"; }
.bi-envelope-plus::before { content: "\f694"; } .bi-envelope-plus::before { content: "\f694"; }
.bi-envelope-slash-1::before { content: "\f695"; }
.bi-envelope-slash-fill::before { content: "\f696"; } .bi-envelope-slash-fill::before { content: "\f696"; }
.bi-envelope-slash::before { content: "\f697"; } .bi-envelope-slash::before { content: "\f697"; }
.bi-envelope-x-1::before { content: "\f698"; }
.bi-envelope-x-fill::before { content: "\f699"; } .bi-envelope-x-fill::before { content: "\f699"; }
.bi-envelope-x::before { content: "\f69a"; } .bi-envelope-x::before { content: "\f69a"; }
.bi-explicit-fill::before { content: "\f69b"; } .bi-explicit-fill::before { content: "\f69b"; }
@@ -1461,8 +1461,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-list-columns-reverse::before { content: "\f69f"; } .bi-list-columns-reverse::before { content: "\f69f"; }
.bi-list-columns::before { content: "\f6a0"; } .bi-list-columns::before { content: "\f6a0"; }
.bi-meta::before { content: "\f6a1"; } .bi-meta::before { content: "\f6a1"; }
.bi-mortorboard-fill::before { content: "\f6a2"; }
.bi-mortorboard::before { content: "\f6a3"; }
.bi-nintendo-switch::before { content: "\f6a4"; } .bi-nintendo-switch::before { content: "\f6a4"; }
.bi-pc-display-horizontal::before { content: "\f6a5"; } .bi-pc-display-horizontal::before { content: "\f6a5"; }
.bi-pc-display::before { content: "\f6a6"; } .bi-pc-display::before { content: "\f6a6"; }
@@ -1481,7 +1479,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-send-check::before { content: "\f6b3"; } .bi-send-check::before { content: "\f6b3"; }
.bi-send-dash-fill::before { content: "\f6b4"; } .bi-send-dash-fill::before { content: "\f6b4"; }
.bi-send-dash::before { content: "\f6b5"; } .bi-send-dash::before { content: "\f6b5"; }
.bi-send-exclamation-1::before { content: "\f6b6"; }
.bi-send-exclamation-fill::before { content: "\f6b7"; } .bi-send-exclamation-fill::before { content: "\f6b7"; }
.bi-send-exclamation::before { content: "\f6b8"; } .bi-send-exclamation::before { content: "\f6b8"; }
.bi-send-fill::before { content: "\f6b9"; } .bi-send-fill::before { content: "\f6b9"; }
@@ -1493,7 +1490,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-send-x::before { content: "\f6bf"; } .bi-send-x::before { content: "\f6bf"; }
.bi-send::before { content: "\f6c0"; } .bi-send::before { content: "\f6c0"; }
.bi-steam::before { content: "\f6c1"; } .bi-steam::before { content: "\f6c1"; }
.bi-terminal-dash-1::before { content: "\f6c2"; }
.bi-terminal-dash::before { content: "\f6c3"; } .bi-terminal-dash::before { content: "\f6c3"; }
.bi-terminal-plus::before { content: "\f6c4"; } .bi-terminal-plus::before { content: "\f6c4"; }
.bi-terminal-split::before { content: "\f6c5"; } .bi-terminal-split::before { content: "\f6c5"; }
@@ -1523,7 +1519,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-usb-symbol::before { content: "\f6dd"; } .bi-usb-symbol::before { content: "\f6dd"; }
.bi-usb::before { content: "\f6de"; } .bi-usb::before { content: "\f6de"; }
.bi-boombox-fill::before { content: "\f6df"; } .bi-boombox-fill::before { content: "\f6df"; }
.bi-displayport-1::before { content: "\f6e0"; }
.bi-displayport::before { content: "\f6e1"; } .bi-displayport::before { content: "\f6e1"; }
.bi-gpu-card::before { content: "\f6e2"; } .bi-gpu-card::before { content: "\f6e2"; }
.bi-memory::before { content: "\f6e3"; } .bi-memory::before { content: "\f6e3"; }
@@ -1536,8 +1531,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-pci-card::before { content: "\f6ea"; } .bi-pci-card::before { content: "\f6ea"; }
.bi-router-fill::before { content: "\f6eb"; } .bi-router-fill::before { content: "\f6eb"; }
.bi-router::before { content: "\f6ec"; } .bi-router::before { content: "\f6ec"; }
.bi-ssd-fill::before { content: "\f6ed"; }
.bi-ssd::before { content: "\f6ee"; }
.bi-thunderbolt-fill::before { content: "\f6ef"; } .bi-thunderbolt-fill::before { content: "\f6ef"; }
.bi-thunderbolt::before { content: "\f6f0"; } .bi-thunderbolt::before { content: "\f6f0"; }
.bi-usb-drive-fill::before { content: "\f6f1"; } .bi-usb-drive-fill::before { content: "\f6f1"; }
@@ -1644,7 +1637,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-filetype-pdf::before { content: "\f756"; } .bi-filetype-pdf::before { content: "\f756"; }
.bi-filetype-php::before { content: "\f757"; } .bi-filetype-php::before { content: "\f757"; }
.bi-filetype-png::before { content: "\f758"; } .bi-filetype-png::before { content: "\f758"; }
.bi-filetype-ppt-1::before { content: "\f759"; }
.bi-filetype-ppt::before { content: "\f75a"; } .bi-filetype-ppt::before { content: "\f75a"; }
.bi-filetype-psd::before { content: "\f75b"; } .bi-filetype-psd::before { content: "\f75b"; }
.bi-filetype-py::before { content: "\f75c"; } .bi-filetype-py::before { content: "\f75c"; }
@@ -1660,7 +1652,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-filetype-txt::before { content: "\f766"; } .bi-filetype-txt::before { content: "\f766"; }
.bi-filetype-wav::before { content: "\f767"; } .bi-filetype-wav::before { content: "\f767"; }
.bi-filetype-woff::before { content: "\f768"; } .bi-filetype-woff::before { content: "\f768"; }
.bi-filetype-xls-1::before { content: "\f769"; }
.bi-filetype-xls::before { content: "\f76a"; } .bi-filetype-xls::before { content: "\f76a"; }
.bi-filetype-xml::before { content: "\f76b"; } .bi-filetype-xml::before { content: "\f76b"; }
.bi-filetype-yml::before { content: "\f76c"; } .bi-filetype-yml::before { content: "\f76c"; }
@@ -1703,56 +1694,38 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-filetype-json::before { content: "\f791"; } .bi-filetype-json::before { content: "\f791"; }
.bi-filetype-pptx::before { content: "\f792"; } .bi-filetype-pptx::before { content: "\f792"; }
.bi-filetype-xlsx::before { content: "\f793"; } .bi-filetype-xlsx::before { content: "\f793"; }
.bi-1-circle-1::before { content: "\f794"; }
.bi-1-circle-fill-1::before { content: "\f795"; }
.bi-1-circle-fill::before { content: "\f796"; } .bi-1-circle-fill::before { content: "\f796"; }
.bi-1-circle::before { content: "\f797"; } .bi-1-circle::before { content: "\f797"; }
.bi-1-square-fill::before { content: "\f798"; } .bi-1-square-fill::before { content: "\f798"; }
.bi-1-square::before { content: "\f799"; } .bi-1-square::before { content: "\f799"; }
.bi-2-circle-1::before { content: "\f79a"; }
.bi-2-circle-fill-1::before { content: "\f79b"; }
.bi-2-circle-fill::before { content: "\f79c"; } .bi-2-circle-fill::before { content: "\f79c"; }
.bi-2-circle::before { content: "\f79d"; } .bi-2-circle::before { content: "\f79d"; }
.bi-2-square-fill::before { content: "\f79e"; } .bi-2-square-fill::before { content: "\f79e"; }
.bi-2-square::before { content: "\f79f"; } .bi-2-square::before { content: "\f79f"; }
.bi-3-circle-1::before { content: "\f7a0"; }
.bi-3-circle-fill-1::before { content: "\f7a1"; }
.bi-3-circle-fill::before { content: "\f7a2"; } .bi-3-circle-fill::before { content: "\f7a2"; }
.bi-3-circle::before { content: "\f7a3"; } .bi-3-circle::before { content: "\f7a3"; }
.bi-3-square-fill::before { content: "\f7a4"; } .bi-3-square-fill::before { content: "\f7a4"; }
.bi-3-square::before { content: "\f7a5"; } .bi-3-square::before { content: "\f7a5"; }
.bi-4-circle-1::before { content: "\f7a6"; }
.bi-4-circle-fill-1::before { content: "\f7a7"; }
.bi-4-circle-fill::before { content: "\f7a8"; } .bi-4-circle-fill::before { content: "\f7a8"; }
.bi-4-circle::before { content: "\f7a9"; } .bi-4-circle::before { content: "\f7a9"; }
.bi-4-square-fill::before { content: "\f7aa"; } .bi-4-square-fill::before { content: "\f7aa"; }
.bi-4-square::before { content: "\f7ab"; } .bi-4-square::before { content: "\f7ab"; }
.bi-5-circle-1::before { content: "\f7ac"; }
.bi-5-circle-fill-1::before { content: "\f7ad"; }
.bi-5-circle-fill::before { content: "\f7ae"; } .bi-5-circle-fill::before { content: "\f7ae"; }
.bi-5-circle::before { content: "\f7af"; } .bi-5-circle::before { content: "\f7af"; }
.bi-5-square-fill::before { content: "\f7b0"; } .bi-5-square-fill::before { content: "\f7b0"; }
.bi-5-square::before { content: "\f7b1"; } .bi-5-square::before { content: "\f7b1"; }
.bi-6-circle-1::before { content: "\f7b2"; }
.bi-6-circle-fill-1::before { content: "\f7b3"; }
.bi-6-circle-fill::before { content: "\f7b4"; } .bi-6-circle-fill::before { content: "\f7b4"; }
.bi-6-circle::before { content: "\f7b5"; } .bi-6-circle::before { content: "\f7b5"; }
.bi-6-square-fill::before { content: "\f7b6"; } .bi-6-square-fill::before { content: "\f7b6"; }
.bi-6-square::before { content: "\f7b7"; } .bi-6-square::before { content: "\f7b7"; }
.bi-7-circle-1::before { content: "\f7b8"; }
.bi-7-circle-fill-1::before { content: "\f7b9"; }
.bi-7-circle-fill::before { content: "\f7ba"; } .bi-7-circle-fill::before { content: "\f7ba"; }
.bi-7-circle::before { content: "\f7bb"; } .bi-7-circle::before { content: "\f7bb"; }
.bi-7-square-fill::before { content: "\f7bc"; } .bi-7-square-fill::before { content: "\f7bc"; }
.bi-7-square::before { content: "\f7bd"; } .bi-7-square::before { content: "\f7bd"; }
.bi-8-circle-1::before { content: "\f7be"; }
.bi-8-circle-fill-1::before { content: "\f7bf"; }
.bi-8-circle-fill::before { content: "\f7c0"; } .bi-8-circle-fill::before { content: "\f7c0"; }
.bi-8-circle::before { content: "\f7c1"; } .bi-8-circle::before { content: "\f7c1"; }
.bi-8-square-fill::before { content: "\f7c2"; } .bi-8-square-fill::before { content: "\f7c2"; }
.bi-8-square::before { content: "\f7c3"; } .bi-8-square::before { content: "\f7c3"; }
.bi-9-circle-1::before { content: "\f7c4"; }
.bi-9-circle-fill-1::before { content: "\f7c5"; }
.bi-9-circle-fill::before { content: "\f7c6"; } .bi-9-circle-fill::before { content: "\f7c6"; }
.bi-9-circle::before { content: "\f7c7"; } .bi-9-circle::before { content: "\f7c7"; }
.bi-9-square-fill::before { content: "\f7c8"; } .bi-9-square-fill::before { content: "\f7c8"; }
@@ -1771,8 +1744,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-browser-edge::before { content: "\f7d5"; } .bi-browser-edge::before { content: "\f7d5"; }
.bi-browser-firefox::before { content: "\f7d6"; } .bi-browser-firefox::before { content: "\f7d6"; }
.bi-browser-safari::before { content: "\f7d7"; } .bi-browser-safari::before { content: "\f7d7"; }
.bi-c-circle-1::before { content: "\f7d8"; }
.bi-c-circle-fill-1::before { content: "\f7d9"; }
.bi-c-circle-fill::before { content: "\f7da"; } .bi-c-circle-fill::before { content: "\f7da"; }
.bi-c-circle::before { content: "\f7db"; } .bi-c-circle::before { content: "\f7db"; }
.bi-c-square-fill::before { content: "\f7dc"; } .bi-c-square-fill::before { content: "\f7dc"; }
@@ -1783,8 +1754,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-car-front::before { content: "\f7e1"; } .bi-car-front::before { content: "\f7e1"; }
.bi-cassette-fill::before { content: "\f7e2"; } .bi-cassette-fill::before { content: "\f7e2"; }
.bi-cassette::before { content: "\f7e3"; } .bi-cassette::before { content: "\f7e3"; }
.bi-cc-circle-1::before { content: "\f7e4"; }
.bi-cc-circle-fill-1::before { content: "\f7e5"; }
.bi-cc-circle-fill::before { content: "\f7e6"; } .bi-cc-circle-fill::before { content: "\f7e6"; }
.bi-cc-circle::before { content: "\f7e7"; } .bi-cc-circle::before { content: "\f7e7"; }
.bi-cc-square-fill::before { content: "\f7e8"; } .bi-cc-square-fill::before { content: "\f7e8"; }
@@ -1803,8 +1772,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-filetype-sql::before { content: "\f7f5"; } .bi-filetype-sql::before { content: "\f7f5"; }
.bi-fire::before { content: "\f7f6"; } .bi-fire::before { content: "\f7f6"; }
.bi-google-play::before { content: "\f7f7"; } .bi-google-play::before { content: "\f7f7"; }
.bi-h-circle-1::before { content: "\f7f8"; }
.bi-h-circle-fill-1::before { content: "\f7f9"; }
.bi-h-circle-fill::before { content: "\f7fa"; } .bi-h-circle-fill::before { content: "\f7fa"; }
.bi-h-circle::before { content: "\f7fb"; } .bi-h-circle::before { content: "\f7fb"; }
.bi-h-square-fill::before { content: "\f7fc"; } .bi-h-square-fill::before { content: "\f7fc"; }
@@ -1813,8 +1780,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-lungs-fill::before { content: "\f7ff"; } .bi-lungs-fill::before { content: "\f7ff"; }
.bi-lungs::before { content: "\f800"; } .bi-lungs::before { content: "\f800"; }
.bi-microsoft-teams::before { content: "\f801"; } .bi-microsoft-teams::before { content: "\f801"; }
.bi-p-circle-1::before { content: "\f802"; }
.bi-p-circle-fill-1::before { content: "\f803"; }
.bi-p-circle-fill::before { content: "\f804"; } .bi-p-circle-fill::before { content: "\f804"; }
.bi-p-circle::before { content: "\f805"; } .bi-p-circle::before { content: "\f805"; }
.bi-p-square-fill::before { content: "\f806"; } .bi-p-square-fill::before { content: "\f806"; }
@@ -1823,8 +1788,6 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-pass::before { content: "\f809"; } .bi-pass::before { content: "\f809"; }
.bi-prescription::before { content: "\f80a"; } .bi-prescription::before { content: "\f80a"; }
.bi-prescription2::before { content: "\f80b"; } .bi-prescription2::before { content: "\f80b"; }
.bi-r-circle-1::before { content: "\f80c"; }
.bi-r-circle-fill-1::before { content: "\f80d"; }
.bi-r-circle-fill::before { content: "\f80e"; } .bi-r-circle-fill::before { content: "\f80e"; }
.bi-r-circle::before { content: "\f80f"; } .bi-r-circle::before { content: "\f80f"; }
.bi-r-square-fill::before { content: "\f810"; } .bi-r-square-fill::before { content: "\f810"; }
@@ -2016,3 +1979,100 @@ url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("wof
.bi-sina-weibo::before { content: "\f8ca"; } .bi-sina-weibo::before { content: "\f8ca"; }
.bi-tencent-qq::before { content: "\f8cb"; } .bi-tencent-qq::before { content: "\f8cb"; }
.bi-wikipedia::before { content: "\f8cc"; } .bi-wikipedia::before { content: "\f8cc"; }
.bi-alphabet-uppercase::before { content: "\f2a5"; }
.bi-alphabet::before { content: "\f68a"; }
.bi-amazon::before { content: "\f68d"; }
.bi-arrows-collapse-vertical::before { content: "\f690"; }
.bi-arrows-expand-vertical::before { content: "\f695"; }
.bi-arrows-vertical::before { content: "\f698"; }
.bi-arrows::before { content: "\f6a2"; }
.bi-ban-fill::before { content: "\f6a3"; }
.bi-ban::before { content: "\f6b6"; }
.bi-bing::before { content: "\f6c2"; }
.bi-cake::before { content: "\f6e0"; }
.bi-cake2::before { content: "\f6ed"; }
.bi-cookie::before { content: "\f6ee"; }
.bi-copy::before { content: "\f759"; }
.bi-crosshair::before { content: "\f769"; }
.bi-crosshair2::before { content: "\f794"; }
.bi-emoji-astonished-fill::before { content: "\f795"; }
.bi-emoji-astonished::before { content: "\f79a"; }
.bi-emoji-grimace-fill::before { content: "\f79b"; }
.bi-emoji-grimace::before { content: "\f7a0"; }
.bi-emoji-grin-fill::before { content: "\f7a1"; }
.bi-emoji-grin::before { content: "\f7a6"; }
.bi-emoji-surprise-fill::before { content: "\f7a7"; }
.bi-emoji-surprise::before { content: "\f7ac"; }
.bi-emoji-tear-fill::before { content: "\f7ad"; }
.bi-emoji-tear::before { content: "\f7b2"; }
.bi-envelope-arrow-down-fill::before { content: "\f7b3"; }
.bi-envelope-arrow-down::before { content: "\f7b8"; }
.bi-envelope-arrow-up-fill::before { content: "\f7b9"; }
.bi-envelope-arrow-up::before { content: "\f7be"; }
.bi-feather::before { content: "\f7bf"; }
.bi-feather2::before { content: "\f7c4"; }
.bi-floppy-fill::before { content: "\f7c5"; }
.bi-floppy::before { content: "\f7d8"; }
.bi-floppy2-fill::before { content: "\f7d9"; }
.bi-floppy2::before { content: "\f7e4"; }
.bi-gitlab::before { content: "\f7e5"; }
.bi-highlighter::before { content: "\f7f8"; }
.bi-marker-tip::before { content: "\f802"; }
.bi-nvme-fill::before { content: "\f803"; }
.bi-nvme::before { content: "\f80c"; }
.bi-opencollective::before { content: "\f80d"; }
.bi-pci-card-network::before { content: "\f8cd"; }
.bi-pci-card-sound::before { content: "\f8ce"; }
.bi-radar::before { content: "\f8cf"; }
.bi-send-arrow-down-fill::before { content: "\f8d0"; }
.bi-send-arrow-down::before { content: "\f8d1"; }
.bi-send-arrow-up-fill::before { content: "\f8d2"; }
.bi-send-arrow-up::before { content: "\f8d3"; }
.bi-sim-slash-fill::before { content: "\f8d4"; }
.bi-sim-slash::before { content: "\f8d5"; }
.bi-sourceforge::before { content: "\f8d6"; }
.bi-substack::before { content: "\f8d7"; }
.bi-threads-fill::before { content: "\f8d8"; }
.bi-threads::before { content: "\f8d9"; }
.bi-transparency::before { content: "\f8da"; }
.bi-twitter-x::before { content: "\f8db"; }
.bi-type-h4::before { content: "\f8dc"; }
.bi-type-h5::before { content: "\f8dd"; }
.bi-type-h6::before { content: "\f8de"; }
.bi-backpack-fill::before { content: "\f8df"; }
.bi-backpack::before { content: "\f8e0"; }
.bi-backpack2-fill::before { content: "\f8e1"; }
.bi-backpack2::before { content: "\f8e2"; }
.bi-backpack3-fill::before { content: "\f8e3"; }
.bi-backpack3::before { content: "\f8e4"; }
.bi-backpack4-fill::before { content: "\f8e5"; }
.bi-backpack4::before { content: "\f8e6"; }
.bi-brilliance::before { content: "\f8e7"; }
.bi-cake-fill::before { content: "\f8e8"; }
.bi-cake2-fill::before { content: "\f8e9"; }
.bi-duffle-fill::before { content: "\f8ea"; }
.bi-duffle::before { content: "\f8eb"; }
.bi-exposure::before { content: "\f8ec"; }
.bi-gender-neuter::before { content: "\f8ed"; }
.bi-highlights::before { content: "\f8ee"; }
.bi-luggage-fill::before { content: "\f8ef"; }
.bi-luggage::before { content: "\f8f0"; }
.bi-mailbox-flag::before { content: "\f8f1"; }
.bi-mailbox2-flag::before { content: "\f8f2"; }
.bi-noise-reduction::before { content: "\f8f3"; }
.bi-passport-fill::before { content: "\f8f4"; }
.bi-passport::before { content: "\f8f5"; }
.bi-person-arms-up::before { content: "\f8f6"; }
.bi-person-raised-hand::before { content: "\f8f7"; }
.bi-person-standing-dress::before { content: "\f8f8"; }
.bi-person-standing::before { content: "\f8f9"; }
.bi-person-walking::before { content: "\f8fa"; }
.bi-person-wheelchair::before { content: "\f8fb"; }
.bi-shadows::before { content: "\f8fc"; }
.bi-suitcase-fill::before { content: "\f8fd"; }
.bi-suitcase-lg-fill::before { content: "\f8fe"; }
.bi-suitcase-lg::before { content: "\f8ff"; }
.bi-suitcase::before { content: "\f900"; }
.bi-suitcase2-fill::before { content: "\f901"; }
.bi-suitcase2::before { content: "\f902"; }
.bi-vignette::before { content: "\f903"; }

View File

@@ -1,5 +1,5 @@
/* Switch SVG logo to white on dark mode */ /* Switch SVG logo to white on dark mode */
html.dark .navbar-light svg { html[data-bs-theme='dark'] .navbar svg {
fill: #fff; fill: #fff;
} }

View File

@@ -4,7 +4,7 @@
{% get_solo 'portal.SiteConfiguration' as site_conf %} {% get_solo 'portal.SiteConfiguration' as site_conf %}
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-bs-theme="auto">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -16,11 +16,11 @@
<link rel="icon" href="{% static "favicon.png" %}" sizes="any"> <link rel="icon" href="{% static "favicon.png" %}" sizes="any">
<link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml"> <link rel="icon" href="{% static "favicon.svg" %}" type="image/svg+xml">
{% if site_conf.use_cdn %} {% if site_conf.use_cdn %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css" rel="stylesheet">
{% else %} {% else %}
<link href="{% static "bootstrap-dark-5@1.1.3/dist/css/bootstrap-nightshade.min.css" %}" rel="stylesheet"> <link href="{% static "bootstrap@5.3.2/dist/css/bootstrap.min.css" %}" rel="stylesheet">
<link href="{% static "bootstrap-icons@1.10.3/font/bootstrap-icons.css" %}" rel="stylesheet"> <link href="{% static "bootstrap-icons@1.11.1/font/bootstrap-icons.css" %}" rel="stylesheet">
{% endif %} {% endif %}
<link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet"> <link href="{% static "css/main.css" %}?v={{ site_conf.version }}" rel="stylesheet">
<style> <style>
@@ -36,20 +36,94 @@
font-size: 3.5rem; font-size: 3.5rem;
} }
} }
.d-light-inline { display: inline !important; }
.d-dark-inline { display: none !important; }
html.dark .d-light-inline { display: none !important; }
html.dark .d-dark-inline { display: inline !important; }
</style> </style>
<script>
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2023 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-bs-theme', 'dark')
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const activeThemeIcon = document.querySelector('.theme-icon-active i')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const biOfActiveBtn = btnToActive.querySelector('.theme-icon i').getAttribute('class')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('class', biOfActiveBtn)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()
</script>
{% block extra_head %} {% block extra_head %}
{{ site_conf.extra_head | safe }} {{ site_conf.extra_head | safe }}
{% endblock %} {% endblock %}
</head> </head>
<body> <body>
<header> <header>
<div class="navbar navbar-light bg-light shadow-sm"> <nav class="navbar navbar-expand-lg bg-body-tertiary shadow-sm">
<div class="container"> <div class="container d-flex">
<div class="me-auto">
<a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center"> <a href="{% url 'index' %}" class="navbar-brand d-flex align-items-center">
<svg class="me-2" width="26" height="16" enable-background="new 0 0 26 26" version="1" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg"> <svg class="me-2" width="26" height="16" enable-background="new 0 0 26 26" version="1" viewBox="0 0 26 16" xmlns="http://www.w3.org/2000/svg">
<path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" enable-background="accumulate" overflow="visible" stroke-width="2" /> <path d="m2.8125 0.0010991a1.0001 1.0001 0 0 0-0.8125 1c0 0.55455-0.44545 1-1 1a1.0001 1.0001 0 0 0-1 1v10a1.0001 1.0001 0 0 0 1 1c0.55455 0 1 0.44546 1 1a1.0001 1.0001 0 0 0 1 1h20a1.0001 1.0001 0 0 0 1-1c0-0.55454 0.44546-1 1-1a1.0001 1.0001 0 0 0 1-1v-10a1.0001 1.0001 0 0 0-1-1c-0.55454 0-1-0.44545-1-1a1.0001 1.0001 0 0 0-1-1h-20a1.0001 1.0001 0 0 0-0.09375 0 1.0001 1.0001 0 0 0-0.09375 0zm0.78125 2h14.406v1h2v-1h2.4062c0.30628 0.76906 0.82469 1.2875 1.5938 1.5938v8.8125c-0.76906 0.30628-1.2875 0.82469-1.5938 1.5938h-2.4062v-1h-2v1h-14.406c-0.30628-0.76906-0.82469-1.2875-1.5938-1.5938v-8.8125c0.76906-0.30628 1.2875-0.82469 1.5938-1.5938zm14.406 2v2h2v-2zm0 3v2h2v-2zm0 3v2h2v-2z" enable-background="accumulate" overflow="visible" stroke-width="2" />
@@ -62,16 +136,14 @@
</svg> </svg>
<strong>{{ site_conf.site_name }}</strong> <strong>{{ site_conf.site_name }}</strong>
</a> </a>
<div class="btn-group" role="group" aria-label="Login menu"> </div>
{% include 'includes/login.html' %} {% include 'includes/login.html' %}
<a id="darkmode-button" class="btn btn-sm btn-outline-dark"><i class="bi bi-moon d-none d-light-inline" title="Switch to dark mode"></i><i class="bi bi-sun d-none d-dark-inline" title="Switch to light mode"></i></a>
</div>
</div>
</div> </div>
</nav>
</header> </header>
<main> <main>
<div class="container py-2"> <div class="container py-2">
<nav class="navbar navbar-expand-lg navbar-light"> <nav class="navbar navbar-expand-lg">
<div class="container-fluid g-0"> <div class="container-fluid g-0">
<a class="navbar-brand" href="{% url 'index' %}">Home</a> <a class="navbar-brand" href="{% url 'index' %}">Home</a>
<div class="navbar-collapse" id="navbarSupportedContent"> <div class="navbar-collapse" id="navbarSupportedContent">
@@ -115,7 +187,7 @@
</div> </div>
</div> </div>
</section> </section>
<div class="album py-4 bg-light"> <div class="album py-4 bg-body-tertiary">
<div class="container"> <div class="container">
{% block carousel %} {% block carousel %}
{% endblock %} {% endblock %}
@@ -129,17 +201,9 @@
</main> </main>
{% include 'includes/footer.html' %} {% include 'includes/footer.html' %}
{% if site_conf.use_cdn %} {% if site_conf.use_cdn %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js"></script>
{% else %} {% else %}
<script src="{% static "bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" %}"></script> <script src="{% static "bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" %}"></script>
<script src="{% static "bootstrap-dark-5@1.1.3/dist/js/darkmode.min.js" %}"></script>
{% endif %} {% endif %}
<!-- script src="https://cdn.jsdelivr.net/npm/masonry-layout@4.2.2/dist/masonry.pkgd.min.js" integrity="sha384-GNFwBvfVxBkLMJpYMOABq3c+d3KnQxudP/mGPkzpZSTYykLBNsZEnG2D9G/X/+7D" crossorigin="anonymous" async></script -->
<script>
document.querySelector("#darkmode-button").onclick = function(e){
darkmode.toggleDarkMode();
}
</script>
</body> </body>
</html> </html>

View File

@@ -10,7 +10,7 @@
<div class="col"> <div class="col">
<div class="card shadow-sm"> <div class="card shadow-sm">
{% for i in d.image.all %} {% for i in d.image.all %}
{% if forloop.first %}<a href="{{d.get_absolute_url}}"><img src="{{ i.image.url }}" alt="Card image cap"></a>{% endif %} {% if forloop.first %}<a href="{{d.get_absolute_url}}"><img class="card-img-top" src="{{ i.image.url }}" alt="Card image cap"></a>{% endif %}
{% endfor %} {% endfor %}
<div class="card-body"> <div class="card-body">
<p class="card-text" style="position: relative;"> <p class="card-text" style="position: relative;">
@@ -38,7 +38,7 @@
<tr> <tr>
<th scope="row">Company</th> <th scope="row">Company</th>
<td> <td>
<a href="{% url 'filtered' _filter="company" search=d.rolling_class.company.safe_name %}"><abbr title="{{ d.rolling_class.company.extended_name }}">{{ d.rolling_class.company }}</abbr></a> <a href="{% url 'filtered' _filter="company" search=d.rolling_class.company.slug %}"><abbr title="{{ d.rolling_class.company.extended_name }}">{{ d.rolling_class.company }}</abbr></a>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -56,12 +56,12 @@
<tr> <tr>
<th width="35%" scope="row">Manufacturer</th> <th width="35%" scope="row">Manufacturer</th>
<td>{%if d.manufacturer %} <td>{%if d.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=d.manufacturer.safe_name %}">{{ d.manufacturer }}{% if d.manufacturer.website %}</a> <a href="{{ d.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} <a href="{% url 'filtered' _filter="manufacturer" search=d.manufacturer.slug %}">{{ d.manufacturer }}{% if d.manufacturer.website %}</a> <a href="{{ d.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td> {% endif %}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Scale</th> <th scope="row">Scale</th>
<td><a href="{% url 'filtered' _filter="scale" search=d.scale.safe_name %}"><abbr title="{{ d.scale.ratio }} - {{ d.scale.tracks }}">{{ d.scale }}</abbr></a></td> <td><a href="{% url 'filtered' _filter="scale" search=d.scale.slug %}"><abbr title="{{ d.scale.ratio }} - {{ d.scale.tracks }}">{{ d.scale }}</abbr></a></td>
</tr> </tr>
<tr> <tr>
<th scope="row">SKU</th> <th scope="row">SKU</th>

View File

@@ -42,7 +42,7 @@
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="company" search=d.safe_name %}">Show all rolling stock</a> <a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="company" search=d.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_company_change' d.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>

View File

@@ -1,8 +1,11 @@
<div class="navbar-collapse justify-content-end" id="loginNavbar">
<ul class="navbar-nav">
<li class="nav-item dropdown">
{% if request.user.is_staff %} {% if request.user.is_staff %}
<button class="btn btn-sm btn-outline-dark dropdown-toggle" type="button" id="dropdownMenu2" data-bs-toggle="dropdown" aria-expanded="false"> <a class="nav-link dropdown-toggle" href="#" role="button" id="dropdownLogin" data-bs-toggle="dropdown" aria-expanded="false">
Welcome back, <strong>{{ request.user }}</strong> Welcome back, <strong>{{ request.user }}</strong>
</button> </a>
<ul class="dropdown-menu" aria-labelledby="dropdownMenu2"> <ul class="dropdown-menu" aria-labelledby="dropdownLogin">
<li><a class="dropdown-item" href="{% url 'admin:roster_rollingstock_changelist' %}">Rolling stock</a></li> <li><a class="dropdown-item" href="{% url 'admin:roster_rollingstock_changelist' %}">Rolling stock</a></li>
<li><a class="dropdown-item" href="{% url 'admin:consist_consist_changelist' %}">Consists</a></li> <li><a class="dropdown-item" href="{% url 'admin:consist_consist_changelist' %}">Consists</a></li>
<li><a class="dropdown-item" href="{% url 'admin:app_list' 'metadata' %}">Metadata</a></li> <li><a class="dropdown-item" href="{% url 'admin:app_list' 'metadata' %}">Metadata</a></li>
@@ -15,5 +18,30 @@
<li><a class="dropdown-item text-danger" href="{% url 'admin:logout' %}?next={{ request.path }}">Logout</a></li> <li><a class="dropdown-item text-danger" href="{% url 'admin:logout' %}?next={{ request.path }}">Logout</a></li>
</ul> </ul>
{% else %} {% else %}
<a class="btn btn-sm btn-outline-dark" href="{% url 'admin:login' %}?next={{ request.path }}">Log in</a> <a class="nav-link" href="{% url 'admin:login' %}?next={{ request.path }}">Log in</a>
{% endif %} {% endif %}
</li>
<li class="nav-item dropdown">
<a class="theme-icon-active nav-link dropdown-toggle" href="#" type="button" id="bd-theme" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-circle-half"></i>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="bd-theme">
<li>
<button class="theme-icon dropdown-item" data-bs-theme-value="light" aria-pressed="false">
<span class="me-2"><i class="bi bi-sun-fill"></i></span> Light
</button>
</li>
<li>
<button class="theme-icon dropdown-item" data-bs-theme-value="dark" aria-pressed="false">
<span class="me-2"><i class="bi bi-moon-stars-fill"></i></span> Dark
</button>
</li>
<li>
<button class="theme-icon dropdown-item" data-bs-theme-value="auto" aria-pressed="false">
<span class="me-2"><i class="bi bi-circle-half"></i></span> Auto
</button>
</li>
</ul>
</li>
</ul>
</div>

View File

@@ -1,6 +1,6 @@
<form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate> <form class="d-flex needs-validation" action="{% url 'search' %}" method="post" novalidate>
<div class="input-group has-validation"> <div class="input-group has-validation">
<input class="form-control me-2" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required> <input class="form-control" type="search" list="datalistOptions" placeholder="Search" aria-label="Search" name="search" id="searchValidation" required>
<datalist id="datalistOptions"> <datalist id="datalistOptions">
<option value="company: "> <option value="company: ">
<option value="manufacturer: "> <option value="manufacturer: ">

View File

@@ -34,7 +34,7 @@
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="manufacturer" search=d.safe_name %}">Show all rolling stock</a> <a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="manufacturer" search=d.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_manufacturer_change' d.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>

View File

@@ -70,7 +70,7 @@
<tr> <tr>
<th scope="row">Company</th> <th scope="row">Company</th>
<td> <td>
<a href="{% url 'filtered' _filter="company" search=rolling_stock.rolling_class.company.safe_name %}"><abbr title="{{ rolling_stock.rolling_class.company.extended_name }}">{{ rolling_stock.rolling_class.company }}</abbr></a> <a href="{% url 'filtered' _filter="company" search=rolling_stock.rolling_class.company.slug %}"><abbr title="{{ rolling_stock.rolling_class.company.extended_name }}">{{ rolling_stock.rolling_class.company }}</abbr></a>
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -97,7 +97,7 @@
<tr> <tr>
<th width="35%" scope="row">Manufacturer</th> <th width="35%" scope="row">Manufacturer</th>
<td>{%if rolling_stock.manufacturer %} <td>{%if rolling_stock.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.safe_name %}">{{ rolling_stock.manufacturer }}{% if rolling_stock.manufacturer.website %}</a> <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} <a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}{% if rolling_stock.manufacturer.website %}</a> <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td> {% endif %}</td>
</tr> </tr>
<tr> <tr>
@@ -147,7 +147,7 @@
<tr> <tr>
<th width="35%" scope="row">Manufacturer</th> <th width="35%" scope="row">Manufacturer</th>
<td>{%if rolling_stock.manufacturer %} <td>{%if rolling_stock.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.safe_name %}">{{ rolling_stock.manufacturer }}{% if rolling_stock.manufacturer.website %}</a> <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} <a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.manufacturer.slug %}">{{ rolling_stock.manufacturer }}{% if rolling_stock.manufacturer.website %}</a> <a href="{{ rolling_stock.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td> {% endif %}</td>
</tr> </tr>
<tr> <tr>
@@ -209,7 +209,7 @@
<tr> <tr>
<th scope="row">Company</th> <th scope="row">Company</th>
<td> <td>
<a href="{% url 'filtered' _filter="company" search=rolling_stock.rolling_class.company.safe_name %}">{{ rolling_stock.rolling_class.company }}</a> ({{ rolling_stock.rolling_class.company.extended_name }}) <a href="{% url 'filtered' _filter="company" search=rolling_stock.rolling_class.company.slug %}">{{ rolling_stock.rolling_class.company }}</a> ({{ rolling_stock.rolling_class.company.extended_name }})
</td> </td>
</tr> </tr>
<tr> <tr>
@@ -219,7 +219,7 @@
<tr> <tr>
<th scope="row">Manufacturer</th> <th scope="row">Manufacturer</th>
<td>{%if rolling_stock.rolling_class.manufacturer %} <td>{%if rolling_stock.rolling_class.manufacturer %}
<a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.rolling_class.manufacturer.safe_name %}">{{ rolling_stock.rolling_class.manufacturer }}{% if rolling_stock.rolling_class.manufacturer.website %}</a> <a href="{{ rolling_stock.rolling_class.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %} <a href="{% url 'filtered' _filter="manufacturer" search=rolling_stock.rolling_class.manufacturer.slug %}">{{ rolling_stock.rolling_class.manufacturer }}{% if rolling_stock.rolling_class.manufacturer.website %}</a> <a href="{{ rolling_stock.rolling_class.manufacturer.website }}" target="_blank"><i class="bi bi-box-arrow-up-right"></i></a>{% endif %}
{% endif %}</td> {% endif %}</td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -32,7 +32,7 @@
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="scale" search=d.safe_name %}">Show all rolling stock</a> <a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="scale" search=d.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>

View File

@@ -24,7 +24,7 @@
</tbody> </tbody>
</table> </table>
<div class="d-grid gap-2 mb-1 d-md-block"> <div class="d-grid gap-2 mb-1 d-md-block">
<a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="type" search=d.safe_name %}">Show all rolling stock</a> <a class="btn btn-sm btn-outline-primary" href="{% url 'filtered' _filter="type" search=d.slug %}">Show all rolling stock</a>
{% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.pk %}">Edit</a>{% endif %} {% if request.user.is_staff %}<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:metadata_scale_change' d.pk %}">Edit</a>{% endif %}
</div> </div>
</div> </div>

View File

@@ -174,33 +174,22 @@ class GetRosterFiltered(View):
def run_filter(self, request, search, _filter, page=1): def run_filter(self, request, search, _filter, page=1):
site_conf = get_site_conf() site_conf = get_site_conf()
if _filter == "type": if _filter == "type":
type_ = " ".join(search.split()[:-1]) title = RollingStockType.objects.get(slug__iexact=search)
category = search.split()[-1] query = Q(rolling_class__type__slug__iexact=search)
try:
title = (
RollingStockType.objects.filter(type__iexact=type_)
.get(category__iexact=category)
)
except ObjectDoesNotExist:
raise Http404
query = Q(
Q(rolling_class__type__type__iexact=type_)
& Q(rolling_class__type__category__iexact=category)
)
elif _filter == "company": elif _filter == "company":
title = get_object_or_404(Company, name__iexact=search) title = get_object_or_404(Company, slug__iexact=search)
query = Q(rolling_class__company__name__iexact=search) query = Q(rolling_class__company__slug__iexact=search)
elif _filter == "manufacturer": elif _filter == "manufacturer":
title = get_object_or_404(Manufacturer, name__iexact=search) title = get_object_or_404(Manufacturer, slug__iexact=search)
query = Q( query = Q(
Q(rolling_class__manufacturer__name__iexact=search) Q(rolling_class__manufacturer__slug__iexact=search)
| Q(manufacturer__name__iexact=search) | Q(manufacturer__slug__iexact=search)
) )
elif _filter == "scale": elif _filter == "scale":
title = get_object_or_404(Scale, scale__iexact=search) title = get_object_or_404(Scale, slug__iexact=search)
query = Q(scale__scale__iexact=search) query = Q(scale__slug__iexact=search)
elif _filter == "tag": elif _filter == "tag":
title = get_object_or_404(Tag, slug=search) title = get_object_or_404(Tag, slug__iexact=search)
query = Q(tags__slug__iexact=search) query = Q(tags__slug__iexact=search)
else: else:
raise Http404 raise Http404

View File

@@ -1,4 +1,4 @@
from ram.utils import git_suffix from ram.utils import git_suffix
__version__ = "0.1.2" __version__ = "0.3.1"
__version__ += git_suffix(__file__) __version__ += git_suffix(__file__)

1
requirements-prod.txt Normal file
View File

@@ -0,0 +1 @@
gunicorn