aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorivar <i@oiee.no>2025-10-26 11:33:38 +0100
committerivar <i@oiee.no>2025-10-26 11:33:38 +0100
commit2e8ad7dc6d49361c6ee00dc628e119c0c06e2779 (patch)
treebecc4cde99eff6ed1d168f9e9c454c0f2bbbee9d
parent8de1187b627625b94ef8088de3e9255ccd17baf4 (diff)
downloadvegadata-2e8ad7dc6d49361c6ee00dc628e119c0c06e2779.tar.xz
vegadata-2e8ad7dc6d49361c6ee00dc628e119c0c06e2779.zip
.
-rw-r--r--.dockerignore25
-rw-r--r--.gitignore19
-rw-r--r--.version1
-rw-r--r--.version-dev0
-rw-r--r--Dockerfile21
-rw-r--r--VegaData.sln16
-rw-r--r--VegaData.sln.DotSettings.user4
-rw-r--r--VegaData/Program.cs140
-rw-r--r--VegaData/Properties/launchSettings.json23
-rw-r--r--VegaData/VegaData.csproj37
-rw-r--r--VegaData/appsettings.json9
-rw-r--r--VegaData/data/main.dbbin0 -> 65536 bytes
-rw-r--r--VegaData/data/main.db-shmbin0 -> 32768 bytes
-rw-r--r--VegaData/data/main.db-walbin0 -> 74192 bytes
-rw-r--r--VegaData/wwwroot/17.pngbin0 -> 5334 bytes
-rw-r--r--VegaData/wwwroot/framework.js166
-rw-r--r--VegaData/wwwroot/index.html139
-rw-r--r--VegaData/wwwroot/index.js152
-rw-r--r--VegaData/wwwroot/version.txt1
-rwxr-xr-xbuild_and_push.sh87
-rw-r--r--readme.md1
21 files changed, 841 insertions, 0 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..cd967fc
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,25 @@
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/.idea
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md \ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5799c07
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,19 @@
+**/.env
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/.idea
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/appsettings.Development.json
+**/charts
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
diff --git a/.version b/.version
new file mode 100644
index 0000000..a97cee7
--- /dev/null
+++ b/.version
@@ -0,0 +1 @@
+v29
diff --git a/.version-dev b/.version-dev
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/.version-dev
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..2aed16f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,21 @@
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
+WORKDIR /app
+EXPOSE 8080
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+WORKDIR /src
+COPY ["VegaData/VegaData.csproj", "VegaData/"]
+RUN dotnet restore "VegaData/VegaData.csproj"
+COPY . .
+WORKDIR "/src/VegaData"
+RUN dotnet build "./VegaData.csproj" -c Release -o /app/build
+
+FROM build AS publish
+RUN dotnet publish "./VegaData.csproj" -c Release -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+ENV TZ=Europe/Oslo
+RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "VegaData.dll"]
diff --git a/VegaData.sln b/VegaData.sln
new file mode 100644
index 0000000..4bd82ae
--- /dev/null
+++ b/VegaData.sln
@@ -0,0 +1,16 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VegaData", "VegaData\VegaData.csproj", "{E87280D2-4158-4394-9D43-7BED29988922}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {E87280D2-4158-4394-9D43-7BED29988922}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {E87280D2-4158-4394-9D43-7BED29988922}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {E87280D2-4158-4394-9D43-7BED29988922}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {E87280D2-4158-4394-9D43-7BED29988922}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+EndGlobal
diff --git a/VegaData.sln.DotSettings.user b/VegaData.sln.DotSettings.user
new file mode 100644
index 0000000..db52641
--- /dev/null
+++ b/VegaData.sln.DotSettings.user
@@ -0,0 +1,4 @@
+<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
+ <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AQueryableMethodTranslatingExpressionVisitor_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fb1978e5b918ec1387750dbcda22ac817cf96bf2e148ea8bc6c6d536746f5cfd0_003FQueryableMethodTranslatingExpressionVisitor_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
+ <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AServiceProvider_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FSourcesCache_003Fdb31f833b312bc92fbd376adfd43aa89f9abb4df5193b7ac4e9f3a4d064b798_003FServiceProvider_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
+ <s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AStackFrameIterator_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E2_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F9ea12d3329264c90b91b5849fef513621051908_003Fe0_003Fd2d5d0f5_003FStackFrameIterator_002Ecs/@EntryIndexedValue">ForceIncluded</s:String></wpf:ResourceDictionary> \ No newline at end of file
diff --git a/VegaData/Program.cs b/VegaData/Program.cs
new file mode 100644
index 0000000..76fd8e2
--- /dev/null
+++ b/VegaData/Program.cs
@@ -0,0 +1,140 @@
+using System.Text.Json.Serialization;
+using Microsoft.EntityFrameworkCore;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.Services.AddHttpClient();
+builder.Services.AddDbContext<Database>(o => o.UseSqlite("Data Source=data/main.db"));
+builder.Services.AddHostedService<DataWorker>();
+
+var app = builder.Build();
+try {
+ using var scope = app.Services.CreateScope();
+ scope.ServiceProvider.GetRequiredService<Database>().Database.EnsureCreated();
+} catch (Exception e) {
+ Console.WriteLine(e);
+}
+
+app.MapStaticAssets();
+app.MapGet("shows", (Database db) => db.Shows.Where(c => c.StartDateTime >= DateTime.Now).ToList());
+app.MapGet("tz", () => TimeZoneInfo.Local.Id);
+app.MapGet("/", () => Results.Redirect("/index.html"));
+app.Run();
+
+class Database(DbContextOptions<Database> options) : DbContext(options)
+{
+ public DbSet<Show> Shows { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder) {
+ modelBuilder.Entity<Show>().HasIndex(p => new {
+ p.Title,
+ p.StartDateTime
+ })
+ .IsUnique();
+ base.OnModelCreating(modelBuilder);
+ }
+}
+
+class Show
+{
+ public int Id { get; set; }
+ public required string Title { get; set; }
+ public required string Scene { get; set; }
+ public required DateTime StartDateTime { get; set; }
+ public required string TicketUrl { get; set; }
+ public required string Type { get; set; }
+ public required string MovieVersion { get; set; }
+ public required string MovieMainVersion { get; set; }
+ public required string[] Tags { get; set; }
+}
+
+class DataWorker(IServiceScopeFactory serviceScopeFactory) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
+ const int interval = 1000 * 60 * 60;
+ while (!stoppingToken.IsCancellationRequested) {
+ try {
+ await Work(stoppingToken);
+ await Task.Delay(interval, stoppingToken);
+ } catch (Exception e) {
+ Console.WriteLine(e);
+ }
+ }
+ }
+
+ async Task Work(CancellationToken ct) {
+ using var scope = serviceScopeFactory.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService<Database>();
+ var http = scope.ServiceProvider.GetRequiredService<HttpClient>();
+ var logger = scope.ServiceProvider.GetRequiredService<ILogger<DataWorker>>();
+ var refreshData = Environment.GetEnvironmentVariable("VEGADATA_REFRESH_DATA") == "1";
+ logger.LogInformation("Syncing data. Refresh={RefreshData}", refreshData);
+ var shows = await http.GetFromJsonAsync<ShowsResponse>("https://www.vegascene.no/api/program", ct);
+ var addedCount = 0;
+ if (refreshData) {
+ File.Copy("data/main.db", $"data/main-{Random.Shared.Next(10000)}.db");
+ db.Shows.RemoveRange(db.Shows);
+ await db.SaveChangesAsync(ct);
+ }
+
+ var count = await db.Shows.CountAsync(ct);
+
+ foreach (var show in shows?.Shows ?? []) {
+ if (string.IsNullOrWhiteSpace(show.TicketSaleUrl)) continue;
+ var existing = await db.Shows.AnyAsync(s => s.Title == show.MovieTitle && s.StartDateTime == show.ShowStart, ct);
+ if (existing) continue;
+ logger.LogInformation("Adding show {Title} at {StartDateTime}", show.MovieTitle, show.ShowStart);
+ db.Shows.Add(new Show() {
+ Title = show.MovieTitle,
+ Scene = show.ScreenName,
+ StartDateTime = show.ShowStart,
+ MovieMainVersion = show.MovieMainVersionId,
+ MovieVersion = show.MovieVersionId,
+ TicketUrl = show.TicketSaleUrl,
+ Type = show.ShowType,
+ Tags = show.VersionTags.Select(t => t.Tag).ToArray()
+ });
+ addedCount++;
+ }
+ await db.SaveChangesAsync(ct);
+ logger.LogInformation("Synced data, old count:{Count}, new count:{AddedCount}", count, addedCount);
+ }
+
+ class ShowsResponse
+ {
+ public required List<VegaShow> Shows { get; set; }
+ }
+
+ class VegaShow
+ {
+ [JsonPropertyName("screenName")]
+ public required string ScreenName { get; set; }
+
+ [JsonPropertyName("ticketSaleUrl")]
+ public required string TicketSaleUrl { get; set; }
+
+ [JsonPropertyName("showType")]
+ public required string ShowType { get; set; }
+
+ [JsonPropertyName("showStart")]
+ public required DateTime ShowStart { get; set; }
+
+ [JsonPropertyName("movieVersionId")]
+ public required string MovieVersionId { get; set; }
+
+ [JsonPropertyName("movieMainVersionId")]
+ public required string MovieMainVersionId { get; set; }
+
+ [JsonPropertyName("movieTitle")]
+ public required string MovieTitle { get; set; }
+
+ [JsonPropertyName("versionTags")]
+ public required VersionTag[] VersionTags { get; set; }
+ }
+
+ public class VersionTag
+ {
+ [JsonPropertyName("tag")]
+ public required string Tag { get; set; }
+ }
+} \ No newline at end of file
diff --git a/VegaData/Properties/launchSettings.json b/VegaData/Properties/launchSettings.json
new file mode 100644
index 0000000..e579794
--- /dev/null
+++ b/VegaData/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5234",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "https://localhost:7097;http://localhost:5234",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/VegaData/VegaData.csproj b/VegaData/VegaData.csproj
new file mode 100644
index 0000000..2604474
--- /dev/null
+++ b/VegaData/VegaData.csproj
@@ -0,0 +1,37 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net10.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9">
+ <PrivateAssets>all</PrivateAssets>
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ </PackageReference>
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <Content Include="..\.dockerignore">
+ <Link>.dockerignore</Link>
+ </Content>
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="data\" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Include="wwwroot\17.png" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <_ContentIncludedByDefault Remove="wwwroot\Baskerville No.2 Regular\Baskerville No.2 Regular.otf" />
+ </ItemGroup>
+
+</Project>
diff --git a/VegaData/appsettings.json b/VegaData/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/VegaData/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/VegaData/data/main.db b/VegaData/data/main.db
new file mode 100644
index 0000000..9b09852
--- /dev/null
+++ b/VegaData/data/main.db
Binary files differ
diff --git a/VegaData/data/main.db-shm b/VegaData/data/main.db-shm
new file mode 100644
index 0000000..ef4b59b
--- /dev/null
+++ b/VegaData/data/main.db-shm
Binary files differ
diff --git a/VegaData/data/main.db-wal b/VegaData/data/main.db-wal
new file mode 100644
index 0000000..0239d4f
--- /dev/null
+++ b/VegaData/data/main.db-wal
Binary files differ
diff --git a/VegaData/wwwroot/17.png b/VegaData/wwwroot/17.png
new file mode 100644
index 0000000..7895530
--- /dev/null
+++ b/VegaData/wwwroot/17.png
Binary files differ
diff --git a/VegaData/wwwroot/framework.js b/VegaData/wwwroot/framework.js
new file mode 100644
index 0000000..d1e6cc6
--- /dev/null
+++ b/VegaData/wwwroot/framework.js
@@ -0,0 +1,166 @@
+const targetMap = new WeakMap();
+let activeEffect = null;
+
+const root = document.getElementById("root");
+
+function createElement(tagName, props = {}, children = []) {
+ const el = document.createElement(tagName);
+
+ for (const [key, value] of Object.entries(props || {})) {
+ if (key.startsWith("on") && typeof value === "function") {
+ el.addEventListener(key.substring(2).toLowerCase(), value);
+ } else if (key === "style" && typeof value === "object") {
+ Object.assign(el.style, value);
+ } else {
+ el.setAttribute(key, value);
+ }
+ }
+
+ const appendChild = (child) => {
+ if (Array.isArray(child)) {
+ child.forEach(appendChild);
+ } else if (typeof child === "function") {
+ const placeholder = document.createTextNode("");
+ el.appendChild(placeholder);
+ e(() => {
+ placeholder.textContent = child() ?? "";
+ });
+ } else if (child instanceof Node) {
+ el.appendChild(child);
+ } else if (child != null) {
+ el.appendChild(document.createTextNode(String(child)));
+ }
+ };
+
+ children.forEach(appendChild);
+ return el;
+}
+
+function track(target, key) {
+ if (!activeEffect) {
+ return;
+ }
+ let depsMap = targetMap.get(target);
+ if (!depsMap) {
+ depsMap = new Map();
+ targetMap.set(target, depsMap);
+ }
+
+ let dep = depsMap.get(key);
+ if (!dep) {
+ dep = new Set();
+ depsMap.set(key, dep);
+ }
+
+ dep.add(activeEffect);
+}
+
+function trigger(target, key) {
+ const depsMap = targetMap.get(target);
+ if (!depsMap) {
+ return;
+ }
+
+ const dep = depsMap.get(key);
+ if (dep) {
+ dep.forEach((effect) => effect());
+ }
+}
+
+//**
+// Create a reactive value, the value is at .value.
+// To use this in element props you need to supply the .value read as a function.
+// */
+export function r(target) {
+ target = { value: target };
+ return new Proxy(target, {
+ get(obj, key, receiver) {
+ track(obj, key);
+ return Reflect.get(obj, key, receiver);
+ },
+ set(obj, key, value, receiver) {
+ const result = Reflect.set(obj, key, value, receiver);
+ trigger(obj, key);
+ return result;
+ },
+ });
+}
+
+//**
+// Run code when value changes
+// */
+export function e(fn) {
+ let active = true;
+
+ const runner = () => {
+ if (active) {
+ activeEffect = runner;
+ fn();
+ activeEffect = null;
+ }
+ };
+
+ runner();
+
+ runner.stop = () => {
+ active = false;
+ };
+
+ return runner;
+}
+
+export function gqp(key) {
+ return new URLSearchParams(location.search).get(key)
+}
+
+export function sqp(query) {
+ if ('URLSearchParams' in window) {
+ const url = new URL(window.location)
+ for (const key of Object.keys(query)) {
+ const value = encodeURIComponent(query[key])
+ if (!value || value === "") url.searchParams.delete(key)
+ else url.searchParams.set(key, value)
+ }
+ history.pushState(null, '', url);
+ }
+}
+
+//**
+// Combine elements
+// */
+export function c(a, b) {
+ const normalize = (x) => (x == null ? [] : Array.isArray(x) ? x : [x]);
+
+ return [...normalize(a), ...normalize(b)];
+}
+
+//**
+// Mount element to a target (target is #root by default)
+// */
+export async function m(component, target = root) {
+ if (typeof component === "function") {
+ component = await component();
+ }
+ if (Array.isArray(component)) {
+ target.append(...component);
+ } else {
+ target.appendChild(component);
+ }
+}
+
+export function css(styleObject) {
+ return Object.entries(styleObject).map(([key, value]) => {
+ const kebabKey = key.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
+ return `${kebabKey}: ${value}`;
+ }).join("; ");
+}
+
+//**
+// Create element
+// */
+export function t(name, props, ...children) {
+ if (typeof name === "function") {
+ return name({ ...props, children });
+ }
+ return createElement(name, props, children);
+}
diff --git a/VegaData/wwwroot/index.html b/VegaData/wwwroot/index.html
new file mode 100644
index 0000000..23fbb8e
--- /dev/null
+++ b/VegaData/wwwroot/index.html
@@ -0,0 +1,139 @@
+<!doctype html>
+<html lang="nb">
+
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <style>
+ * {
+ box-sizing: border-box;
+ }
+
+ :root {
+ --bg: beige;
+ }
+
+ #search {
+ padding: 10px 12px;
+ min-width: 300px;
+ }
+
+ html,
+ body {
+ margin: 0;
+ padding: 0;
+ background: var(--bg);
+ font-family: Palatino, Georgia, serif;
+ }
+
+ li:target,
+ .activeShow {
+ padding-block: 2rem;
+ padding-inline: 1rem;
+ background-image: url("./17.png");
+
+ div:first-of-type {
+ padding-block: .4rem;
+ padding-inline-start: .5rem;
+ background-color: var(--bg);
+ }
+ }
+
+ #ulShows {
+ height: 80vh;
+ overflow-x: auto;
+ }
+
+ #times {
+ padding-left: 15px;
+ }
+
+ main {
+ padding-inline: 10px;
+ }
+
+ .show {
+ margin-bottom: 1rem;
+ width: 100%;
+
+ .title {
+ position: sticky;
+ top: 0;
+ width: 100%;
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ background: bisque;
+ padding-inline: 3px;
+ padding-bottom: 5px;
+
+ h2 {
+ font-weight: 600;
+ overflow-wrap: break-word;
+ hyphens: manual;
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+ }
+ }
+
+ .time {
+ font-style: normal;
+ font-size: 1rem;
+ text-decoration: none;
+ font-weight: 400;
+ margin-bottom: 15px;
+
+ .date::first-letter {
+ border-left: 2px solid gold;
+ padding-left: 2px;
+ }
+
+ div {
+ display: flex;
+ flex-direction: column;
+
+ span:first-of-type {
+ font-size: 1.2rem;
+ }
+ }
+
+ .actions {
+ display: flex;
+ flex-direction: row;
+ gap: 1rem;
+ }
+ }
+
+ h1 {
+ margin: 0;
+ padding: 10px;
+ }
+
+ ul {
+ list-style: none;
+ margin: 10px 0 0 0;
+ padding: 0;
+ }
+ </style>
+ <title>Vega eller</title>
+</head>
+
+<body>
+ <h1>Bli med på vega?</h1>
+ <main>
+ <button id="renderShowsBtn">tilbakestill</button> <br>
+ <input type="search" name="q1" id="search" placeholder="søk">
+ <ul id="ulShows"></ul>
+ </main>
+ <script type="importmap">
+ {
+ "imports": {
+ "temporal-polyfill": "https://esm.sh/temporal-polyfill@0.3.0"
+ }
+ }
+ </script>
+ <script type="module" src="index.js"></script>
+</body>
+
+</html> \ No newline at end of file
diff --git a/VegaData/wwwroot/index.js b/VegaData/wwwroot/index.js
new file mode 100644
index 0000000..45af7f2
--- /dev/null
+++ b/VegaData/wwwroot/index.js
@@ -0,0 +1,152 @@
+import { Temporal } from "temporal-polyfill";
+import { gqp, sqp, t } from "./framework.js";
+
+let _;
+
+async function getShows() {
+ if (_) return _;
+ const response = await fetch("/shows");
+ _ = await response.json();
+ return _;
+}
+
+const ulShows = document.getElementById("ulShows");
+const search = document.getElementById("search");
+const renderShowsBtn = document.getElementById("renderShowsBtn");
+
+renderShowsBtn.addEventListener("click", () => {
+ renderShows("");
+ search.value = "";
+});
+
+function dateString(date, small = false) {
+ const instant = Temporal.Instant.from(`${date}Z`)
+ const stringOptions = {
+ weekday: "long",
+ hour: "2-digit",
+ timeZone: "UTC",
+ minute: "2-digit",
+ month: "long",
+ calendar: "gregory",
+ day: "numeric"
+ }
+
+ if (small) {
+ return instant.toLocaleString("nb-NO", stringOptions);
+ }
+
+ return instant.toLocaleString("nb-NO", {
+ year: "numeric",
+ era: "long",
+ ...stringOptions
+ });
+}
+
+function copyLink(t, e) {
+ const initialInnerText = t.target.innerText;
+ if ("clipboard" in navigator) {
+ navigator.clipboard.writeText(`${location.origin}/index.html#${urlId(e)}`)
+ t.target.innerText = `${initialInnerText} ✓`
+ setTimeout(() => {
+ t.target.innerText = initialInnerText
+ }, 1000)
+ }
+}
+
+function vegascene(showing) {
+ if (showing.movieMainVersion.startsWith("KUL")) return `https://www.vegascene.no/teater/${showing.movieMainVersion}`
+ return `https://www.vegascene.no/film/${showing.movieMainVersion}`
+}
+
+function urlId(e) {
+ return `${e.movieVersion}${e.startDateTime}`.replaceAll("-", "").replaceAll(" ", "").replaceAll(":", "")
+}
+
+async function renderShows(query, rendered) {
+ query = query?.trim();
+ sqp({ q: query ?? "" })
+
+ const shows = (await getShows()).reduce((acc, curr) => {
+ const key = curr.title;
+ if (!acc[key]) {
+ acc[key] = [];
+ }
+ acc[key].push(curr);
+ return acc;
+ }, {});
+
+ async function share(show) {
+ const shareData = {
+ title: `${show.title} ${dateString(show.startDateTime, true)} på vega`,
+ url: `${location.origin}/index.html#${urlId(show)}`,
+ }
+ await navigator.share(shareData);
+ }
+
+ const lis = [];
+
+ for (const showKey of Object.keys(shows).sort((a, b) => a.localeCompare(b))) {
+ const times = shows[showKey].sort((a, b) => Temporal.PlainDate.compare(Temporal.PlainDate.from(a.startDateTime), Temporal.PlainDate.from(b.startDateTime)))
+ if (query) {
+ const words = [showKey.toLowerCase()]
+ for (const time of times) {
+ words.push(dateString(time.startDateTime))
+ words.push(time.scene)
+ words.push(time.tags.join(" "))
+ }
+ if (!words.some((word) => word.match(query.toLowerCase()))) continue;
+ }
+
+ lis.push(
+ t("li", { class: "show", id: showKey }, [
+ t("div", { class: "title" }, [
+ t("h2", { class: "italic" }, showKey),
+ t("a", { href: vegascene(shows[showKey][0]) }, "(Åpne på vegascene.no)")
+ ]),
+ t("ul", { id: "times" }, [
+ t("li", undefined,
+ times.filter(e => e.ticketUrl !== "").map(e => {
+ let tagLine = `${e.scene} - ${[e.type, ...e.tags].join(", ")}`
+ tagLine = tagLine[0].toUpperCase() + tagLine.slice(1)
+ let dateLine = dateString(e.startDateTime)
+ dateLine = dateLine[0].toUpperCase() + dateLine.slice(1)
+ return t("li", { class: `time time-${e.id}`, id: urlId(e) }, [
+ t("div", undefined, [
+ t("span", { title: e.startDateTime, class: "date" }, dateLine),
+ t("span", undefined, tagLine),
+ t("div", { class: "actions" }, [
+ t("a", { href: e.ticketUrl }, "Billetter"),
+ "share" in navigator ? t("button", { onclick: () => share(e) }, "Del tid") : null,
+ t("button", { onclick: (t) => copyLink(t, e) }, "Kopier lenke")
+ ])
+ ])
+ ])
+ }))
+ ])
+ ])
+ )
+ }
+
+ if (!lis.length) ulShows.replaceChildren(...[t("i", undefined, "Dessverre")])
+ else ulShows.replaceChildren(...lis);
+
+ const interval = setInterval(() => {
+ const element = document.querySelector("#ulShows");
+ if (element.childNodes.length > 0) {
+ clearInterval(interval);
+ rendered();
+ }
+ }, 50);
+}
+
+renderShows(search.value, () => {
+ if (gqp("q")) search.value = gqp("q")
+ search.addEventListener("input", e => renderShows(e.currentTarget.value));
+ const id = location.href.indexOf("#") !== -1 ? location.href.substring(location.href.indexOf("#")) : ""
+ if (id !== "") {
+ const target = document.querySelector(id)
+ target.scrollIntoView({ behavior: "smooth", block: "center" })
+ target.classList.add("activeShow")
+ document.querySelectorAll(".time").forEach(el => el.id !== id.slice(1) && el.classList.remove("activeShow"))
+ }
+}); \ No newline at end of file
diff --git a/VegaData/wwwroot/version.txt b/VegaData/wwwroot/version.txt
new file mode 100644
index 0000000..a97cee7
--- /dev/null
+++ b/VegaData/wwwroot/version.txt
@@ -0,0 +1 @@
+v29
diff --git a/build_and_push.sh b/build_and_push.sh
new file mode 100755
index 0000000..7935718
--- /dev/null
+++ b/build_and_push.sh
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+
+set -Eueo pipefail
+
+CURRENT_DEV_VERSION=$(cat .version-dev)
+CURRENT_DEV_VERSION_INT=${CURRENT_DEV_VERSION//[!0-9]/}
+CURRENT_VERSION=$(cat .version)
+CURRENT_VERSION_INT=${CURRENT_VERSION//[!0-9]/}
+if [ ${1-prod} == "dev" ]; then
+ NEW_VERSION="v$((CURRENT_DEV_VERSION_INT + 1))-dev"
+else
+ NEW_VERSION="v$((CURRENT_VERSION_INT + 1))"
+fi
+IMAGE_NAME="vegadata/server"
+HUB_NAME="dr.ivar.systems/vegadata/server"
+
+# Check for uncommited changes and optionally commit them
+if [ "$(git status --untracked-files=no --porcelain)" ]; then
+ echo "Unclean git tree! press CTRL+C to exit or press ENTER to commit and push to the default branch"
+ read -n 1
+
+ read -p "Enter commit message: " COMMIT_MESSAGE
+ git add .
+ git commit --quiet -m "$COMMIT_MESSAGE"
+fi
+
+if [ ${1-prod} == "dev" ]; then
+ echo $NEW_VERSION >|.version-dev
+ git add .version-dev
+else
+ echo $NEW_VERSION >|.version
+ git add .version
+fi
+
+echo "Starting build of $HUB_NAME:$NEW_VERSION at $(date -u)..."
+echo
+
+# Put version.txt inside of server
+pushd VegaData/wwwroot
+echo "$NEW_VERSION" >version.txt
+git add version.txt
+popd
+
+git commit --quiet -m "chore(release): Bump version"
+
+read -p "Do you want to tag this build? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]; then
+ read -p "Enter tag message (can be empty): " TAG_MESSAGE
+ git tag -am "$TAG_MESSAGE" $NEW_VERSION
+fi
+
+read -p "Do you want to push the latest commits and tags to origin? (y/n) " -n 1 -r
+echo
+if [[ $REPLY =~ ^[Yy]$ ]]; then
+ echo "Pushing latest changes to remotes..."
+ echo
+ git push --quiet --follow-tags
+fi
+
+# Build podman image
+echo "Building podman image"
+echo
+
+podman build --arch=linux/amd64 -t $IMAGE_NAME:$NEW_VERSION .
+
+podman tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:$NEW_VERSION
+
+if [ ${1-prod} == "dev" ]; then
+ podman tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest-dev
+fi
+if [ ${1-prod} == "prod" ]; then
+ podman tag $IMAGE_NAME:$NEW_VERSION $HUB_NAME:latest
+fi
+
+# Optionally push images to podman registry
+echo "Press CTRL+C to exit or press ENTER to push podman image to registry"
+read -n 1
+podman push $HUB_NAME:$NEW_VERSION
+
+if [ ${1-prod} == "dev" ]; then
+ podman push $HUB_NAME:latest-dev
+fi
+
+if [ ${1-prod} == "prod" ]; then
+ podman push $HUB_NAME:latest
+fi
diff --git a/readme.md b/readme.md
new file mode 100644
index 0000000..98350b9
--- /dev/null
+++ b/readme.md
@@ -0,0 +1 @@
+Se live her: https://vega.oiee.no