mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add channels page
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
pkgs.mkShell {
|
||||
packages = [
|
||||
pkgs.openssl
|
||||
pkgs.electron
|
||||
pkgs.openjdk
|
||||
pkgs.maven
|
||||
pkgs.nodejs
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
24
src/main/node/app.js
Normal 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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
54
src/main/resources/static/js/landing.js
Normal file
54
src/main/resources/static/js/landing.js
Normal 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();
|
||||
});
|
||||
65
src/main/resources/templates/channels.html
Normal file
65
src/main/resources/templates/channels.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user