Add channels page

This commit is contained in:
2025-12-10 18:22:04 +01:00
parent 009ecdc2ca
commit cd77b08df2
10 changed files with 242 additions and 3 deletions

View File

@@ -29,9 +29,11 @@ public class SecurityConfig {
"/actuator/health",
"/v3/api-docs/**",
"/swagger-ui.html",
"/swagger-ui/**"
"/swagger-ui/**",
"/channels"
).permitAll()
.requestMatchers(HttpMethod.GET, "/view/*/broadcast").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll()
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content").permitAll()

View File

@@ -0,0 +1,25 @@
package com.imgfloat.app.controller;
import com.imgfloat.app.service.ChannelDirectoryService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/channels")
public class ChannelDirectoryApiController {
private final ChannelDirectoryService channelDirectoryService;
public ChannelDirectoryApiController(ChannelDirectoryService channelDirectoryService) {
this.channelDirectoryService = channelDirectoryService;
}
@GetMapping
public List<String> listChannels(@RequestParam(value = "q", required = false) String query) {
return channelDirectoryService.searchBroadcasters(query);
}
}

View File

@@ -32,6 +32,12 @@ public class ViewController {
return "index";
}
@org.springframework.web.bind.annotation.GetMapping("/channels")
public String channelDirectory() {
LOG.info("Rendering channel directory");
return "channels";
}
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
OAuth2AuthenticationToken authentication,

View File

@@ -39,6 +39,8 @@ import java.util.Base64;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
@@ -75,6 +77,18 @@ public class ChannelDirectoryService {
.orElseGet(() -> channelRepository.save(new Channel(normalized)));
}
public List<String> searchBroadcasters(String query) {
String normalizedQuery = normalize(query);
return channelRepository.findAll().stream()
.map(Channel::getBroadcaster)
.map(this::normalize)
.filter(Objects::nonNull)
.filter(name -> normalizedQuery == null || normalizedQuery.isBlank() || name.contains(normalizedQuery))
.sorted()
.limit(50)
.toList();
}
public boolean addAdmin(String broadcaster, String username) {
Channel channel = getOrCreateChannel(broadcaster);
boolean added = channel.addAdmin(username);
@@ -298,7 +312,7 @@ public class ChannelDirectoryService {
}
private String normalize(String value) {
return value == null ? null : value.toLowerCase();
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
private List<AssetView> sortAndMapAssets(String broadcaster, Collection<Asset> assets) {

24
src/main/node/app.js Normal file
View File

@@ -0,0 +1,24 @@
const { app, BrowserWindow } = require('electron');
function createWindow() {
const url = process.env.ELECTRON_START_URL || "https://imgfloat.kruhlmann.dev/channels";
const width = Number.parseInt(process.env.ELECTRON_WINDOW_WIDTH, 10) || 1920;
const height = Number.parseInt(process.env.ELECTRON_WINDOW_HEIGHT, 10) || 1080;
const win = new BrowserWindow({
width: width,
height: height,
transparent: true,
frame: false,
alwaysOnTop: false,
webPreferences: {
backgroundThrottling: false
}
});
win.loadURL(url);
}
app.whenReady().then(() => {
createWindow();
});

View File

@@ -188,6 +188,50 @@ body {
box-shadow: inset 0 1px 0 rgba(255,255,255,0.02);
}
.search-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.text-input {
width: 100%;
padding: 12px;
border-radius: 10px;
border: 1px solid #1f2937;
background: #0f172a;
color: #e2e8f0;
font-size: 15px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.text-input:focus {
outline: none;
border-color: #7c3aed;
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25);
}
.search-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.search-row {
display: flex;
gap: 10px;
align-items: stretch;
}
.search-row .text-input {
flex: 1;
}
.search-row .button {
padding: 0 16px;
height: 46px;
}
.badge {
display: inline-flex;
align-items: center;
@@ -1133,6 +1177,10 @@ body {
font-size: 0.9em;
}
.tiny {
font-size: 13px;
}
.range-value {
color: #a5b4fc;
font-size: 12px;

View File

@@ -0,0 +1,54 @@
document.addEventListener("DOMContentLoaded", () => {
const searchForm = document.getElementById("channel-search-form");
const searchInput = document.getElementById("channel-search");
const suggestions = document.getElementById("channel-suggestions");
if (!searchForm || !searchInput || !suggestions) {
return;
}
let channels = [];
function updateSuggestions(term) {
const normalizedTerm = term.trim().toLowerCase();
const filtered = channels
.filter((name) => !normalizedTerm || name.includes(normalizedTerm))
.slice(0, 20);
suggestions.innerHTML = "";
filtered.forEach((name) => {
const option = document.createElement("option");
option.value = name;
suggestions.appendChild(option);
});
}
async function loadChannels() {
try {
const response = await fetch("/api/channels");
if (!response.ok) {
throw new Error(`Failed to load channels: ${response.status}`);
}
channels = await response.json();
updateSuggestions(searchInput.value || "");
} catch (error) {
console.error("Could not load channel directory", error);
}
}
searchInput.addEventListener("input", (event) => {
updateSuggestions(event.target.value || "");
});
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
const broadcaster = (searchInput.value || "").trim().toLowerCase();
if (!broadcaster) {
searchInput.focus();
return;
}
window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`;
});
loadChannels();
});

View File

@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Browse channels - Imgfloat</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body class="landing-body">
<div class="landing">
<header class="landing-header">
<div class="brand">
<div class="brand-mark">IF</div>
<div>
<div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div>
</div>
</div>
<div class="cta-row">
<a class="button ghost" href="/">Home</a>
<a class="button" href="/oauth2/authorization/twitch">Login</a>
</div>
</header>
<main class="hero hero-compact">
<div class="hero-text">
<p class="eyebrow">Broadcast views</p>
<h1>Open a public channel overlay.</h1>
<p class="lead">Search any broadcaster with published overlays and jump straight to their on-stream view without logging in.</p>
<ul class="pill-list minimal">
<li>Autocomplete from all published overlays</li>
<li>Direct broadcast view links</li>
</ul>
</div>
<div class="hero-panel search-panel">
<div class="panel-header">
<div>
<p class="eyebrow subtle">Search</p>
<h3>Find a broadcaster</h3>
</div>
<span class="badge">Live</span>
</div>
<p class="muted">Type a channel name to get autocomplete suggestions and open the broadcast overlay.</p>
<form id="channel-search-form" class="search-form">
<label class="sr-only" for="channel-search">Search by broadcaster</label>
<div class="search-row">
<input id="channel-search" name="channel" class="text-input" type="text" list="channel-suggestions" placeholder="Search broadcaster..." autocomplete="off" />
<datalist id="channel-suggestions"></datalist>
<button type="submit" class="button secondary">Open</button>
</div>
<p class="muted tiny">Autocomplete shows all broadcasters with published overlays.</p>
</form>
</div>
</main>
<footer class="landing-footer compact">
<div>
<div class="brand-title">Imgfloat</div>
<div class="muted">Ready when you go live.</div>
</div>
<a class="button ghost" href="/oauth2/authorization/twitch">Login</a>
</footer>
</div>
<script src="/js/landing.js"></script>
</body>
</html>

View File

@@ -25,7 +25,7 @@
<p class="lead">Upload artwork, drop it into a shared dashboard, and stay in sync with your mods.</p>
<div class="cta-row">
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
<span class="muted">Secure OAuth login. No bots needed.</span>
<a class="button ghost" href="/channels">Browse channels</a>
</div>
<ul class="pill-list minimal">
<li>Instant overlay updates</li>