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 {
|
pkgs.mkShell {
|
||||||
packages = [
|
packages = [
|
||||||
pkgs.openssl
|
pkgs.openssl
|
||||||
|
pkgs.electron
|
||||||
pkgs.openjdk
|
pkgs.openjdk
|
||||||
pkgs.maven
|
pkgs.maven
|
||||||
pkgs.nodejs
|
pkgs.nodejs
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ public class SecurityConfig {
|
|||||||
"/actuator/health",
|
"/actuator/health",
|
||||||
"/v3/api-docs/**",
|
"/v3/api-docs/**",
|
||||||
"/swagger-ui.html",
|
"/swagger-ui.html",
|
||||||
"/swagger-ui/**"
|
"/swagger-ui/**",
|
||||||
|
"/channels"
|
||||||
).permitAll()
|
).permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/view/*/broadcast").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/*/assets/visible").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll()
|
.requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content").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";
|
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")
|
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin")
|
||||||
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||||
OAuth2AuthenticationToken authentication,
|
OAuth2AuthenticationToken authentication,
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import java.util.Base64;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import javax.imageio.ImageIO;
|
import javax.imageio.ImageIO;
|
||||||
import javax.imageio.ImageReader;
|
import javax.imageio.ImageReader;
|
||||||
@@ -75,6 +77,18 @@ public class ChannelDirectoryService {
|
|||||||
.orElseGet(() -> channelRepository.save(new Channel(normalized)));
|
.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) {
|
public boolean addAdmin(String broadcaster, String username) {
|
||||||
Channel channel = getOrCreateChannel(broadcaster);
|
Channel channel = getOrCreateChannel(broadcaster);
|
||||||
boolean added = channel.addAdmin(username);
|
boolean added = channel.addAdmin(username);
|
||||||
@@ -298,7 +312,7 @@ public class ChannelDirectoryService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String normalize(String value) {
|
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) {
|
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);
|
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 {
|
.badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1133,6 +1177,10 @@ body {
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tiny {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.range-value {
|
.range-value {
|
||||||
color: #a5b4fc;
|
color: #a5b4fc;
|
||||||
font-size: 12px;
|
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>
|
<p class="lead">Upload artwork, drop it into a shared dashboard, and stay in sync with your mods.</p>
|
||||||
<div class="cta-row">
|
<div class="cta-row">
|
||||||
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
|
<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>
|
</div>
|
||||||
<ul class="pill-list minimal">
|
<ul class="pill-list minimal">
|
||||||
<li>Instant overlay updates</li>
|
<li>Instant overlay updates</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user