Replace master branch with local files
This commit is contained in:
commit
875a53f499
60 changed files with 21637 additions and 0 deletions
323
frontend/src/app.css
Normal file
323
frontend/src/app.css
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--primary: #561d5e;
|
||||
--black: #000;
|
||||
--white: #fff;
|
||||
--gray: #888;
|
||||
--light-gray: #f5f5f5;
|
||||
--error: #dc3545;
|
||||
--success: #28a745;
|
||||
--border: #333;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--black);
|
||||
color: var(--white);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
max-width: 400px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
background: #111;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
background: var(--black);
|
||||
color: var(--white);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
button, .btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled),
|
||||
.btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--error);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--success);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.nav {
|
||||
background: #111;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.card {
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type="file"] {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
}
|
||||
|
||||
.pgp-key {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fingerprint {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.table th {
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--primary);
|
||||
color: var(--white);
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.badge-admin {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--gray);
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
/* Ensure stream pages have black background */
|
||||
html {
|
||||
background: var(--black);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: var(--gray);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-small {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
23
frontend/src/app.d.ts
vendored
Normal file
23
frontend/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
user?: {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
|
||||
interface Window {
|
||||
OvenPlayer: any;
|
||||
Hls: any;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
29
frontend/src/app.html
Normal file
29
frontend/src/app.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<title>Live Streaming Platform</title>
|
||||
|
||||
<style>
|
||||
/* Global reset for better player scaling */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
36
frontend/src/lib/api.js
Normal file
36
frontend/src/lib/api.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost/api';
|
||||
|
||||
async function fetchAPI(endpoint, options = {}) {
|
||||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
credentials: 'include', // Always include credentials
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getStreamKey() {
|
||||
return fetchAPI('/stream/key');
|
||||
}
|
||||
|
||||
export async function regenerateStreamKey() {
|
||||
return fetchAPI('/stream/key/regenerate', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateStreamKey(key) {
|
||||
return fetchAPI(`/stream/validate/${key}`);
|
||||
}
|
||||
|
||||
export async function getStreamStats(streamKey) {
|
||||
return fetchAPI(`/stream/stats/${streamKey}`);
|
||||
}
|
||||
99
frontend/src/lib/pgp.js
Normal file
99
frontend/src/lib/pgp.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// Client-side PGP utilities - wraps openpgp for browser-only usage
|
||||
|
||||
export async function generateKeyPair(username, passphrase = '') {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('PGP operations can only be performed in the browser');
|
||||
}
|
||||
|
||||
const { generateKey, readKey } = await import('openpgp');
|
||||
|
||||
const { privateKey, publicKey } = await generateKey({
|
||||
type: 'rsa',
|
||||
rsaBits: 2048,
|
||||
userIDs: [{ name: username }],
|
||||
passphrase
|
||||
});
|
||||
|
||||
const key = await readKey({ armoredKey: publicKey });
|
||||
const fingerprint = key.getFingerprint();
|
||||
|
||||
return {
|
||||
privateKey,
|
||||
publicKey,
|
||||
fingerprint
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFingerprint(publicKey) {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
try {
|
||||
const { readKey } = await import('openpgp');
|
||||
const key = await readKey({ armoredKey: publicKey });
|
||||
return key.getFingerprint();
|
||||
} catch (error) {
|
||||
console.error('Error getting fingerprint:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signMessage(message, privateKeyArmored, passphrase = '') {
|
||||
if (typeof window === 'undefined') {
|
||||
throw new Error('PGP operations can only be performed in the browser');
|
||||
}
|
||||
|
||||
const { decryptKey, readPrivateKey, createMessage, sign } = await import('openpgp');
|
||||
|
||||
const privateKey = await decryptKey({
|
||||
privateKey: await readPrivateKey({ armoredKey: privateKeyArmored }),
|
||||
passphrase
|
||||
});
|
||||
|
||||
const unsignedMessage = await createMessage({ text: message });
|
||||
const signature = await sign({
|
||||
message: unsignedMessage,
|
||||
signingKeys: privateKey,
|
||||
detached: true
|
||||
});
|
||||
|
||||
return signature;
|
||||
}
|
||||
|
||||
export async function verifySignature(message, signature, publicKeyArmored) {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
const { readKey, readSignature, createMessage, verify } = await import('openpgp');
|
||||
|
||||
const publicKey = await readKey({ armoredKey: publicKeyArmored });
|
||||
const signatureObj = await readSignature({ armoredSignature: signature });
|
||||
const messageObj = await createMessage({ text: message });
|
||||
|
||||
const verificationResult = await verify({
|
||||
message: messageObj,
|
||||
signature: signatureObj,
|
||||
verificationKeys: publicKey
|
||||
});
|
||||
|
||||
const { verified } = verificationResult.signatures[0];
|
||||
return await verified;
|
||||
} catch (error) {
|
||||
console.error('Signature verification error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function storePrivateKey(privateKey) {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('pgp_private_key', privateKey);
|
||||
}
|
||||
|
||||
export function getStoredPrivateKey() {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('pgp_private_key');
|
||||
}
|
||||
|
||||
export function removeStoredPrivateKey() {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('pgp_private_key');
|
||||
}
|
||||
128
frontend/src/lib/stores/auth.js
Normal file
128
frontend/src/lib/stores/auth.js
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { writable, derived } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
function createAuthStore() {
|
||||
const { subscribe, set, update } = writable({
|
||||
user: null,
|
||||
token: null,
|
||||
loading: true
|
||||
});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
async init() {
|
||||
if (!browser) return;
|
||||
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) {
|
||||
set({ user: null, token: null, loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/me', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
set({ user: data.user, token, loading: false });
|
||||
} else {
|
||||
localStorage.removeItem('auth_token');
|
||||
set({ user: null, token: null, loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth init error:', error);
|
||||
set({ user: null, token: null, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
async login(credentials) {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
set({ user: data.user, token: data.token, loading: false });
|
||||
goto('/');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: data.error || 'Invalid credentials' };
|
||||
},
|
||||
|
||||
async loginWithPgp(username, signature, challenge) {
|
||||
const response = await fetch('/api/auth/pgp-verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, signature, challenge })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
set({ user: data.user, token: data.token, loading: false });
|
||||
goto('/');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: data.error || 'Invalid signature' };
|
||||
},
|
||||
|
||||
async register(userData) {
|
||||
const response = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
return { success: true, userId: data.userId };
|
||||
}
|
||||
|
||||
return { success: false, error: data.error || 'Registration failed' };
|
||||
},
|
||||
|
||||
updateUser(userData) {
|
||||
update(state => ({
|
||||
...state,
|
||||
user: userData
|
||||
}));
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem('auth_token');
|
||||
set({ user: null, token: null, loading: false });
|
||||
goto('/login');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const auth = createAuthStore();
|
||||
|
||||
export const isAuthenticated = derived(
|
||||
auth,
|
||||
$auth => !!$auth.user
|
||||
);
|
||||
|
||||
export const isAdmin = derived(
|
||||
auth,
|
||||
$auth => $auth.user?.isAdmin || false
|
||||
);
|
||||
|
||||
export const isStreamer = derived(
|
||||
auth,
|
||||
$auth => $auth.user?.isStreamer || false
|
||||
);
|
||||
56
frontend/src/lib/websocket.js
Normal file
56
frontend/src/lib/websocket.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
let ws = null;
|
||||
let reconnectTimeout = null;
|
||||
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost/ws';
|
||||
|
||||
export function connectWebSocket(onMessage) {
|
||||
if (ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
// WebSocket doesn't support withCredentials, but cookies are sent automatically
|
||||
// on same-origin requests
|
||||
ws = new WebSocket(`${WS_URL}/stream`);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
ws?.send(JSON.stringify({ type: 'subscribe' }));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
onMessage(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
// Reconnect after 5 seconds
|
||||
reconnectTimeout = setTimeout(() => {
|
||||
connectWebSocket(onMessage);
|
||||
}, 5000);
|
||||
};
|
||||
}
|
||||
|
||||
export function disconnectWebSocket() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
reconnectTimeout = null;
|
||||
}
|
||||
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendMessage(message) {
|
||||
if (ws?.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
228
frontend/src/routes/+layout.svelte
Normal file
228
frontend/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { auth, isAuthenticated, isAdmin, isStreamer } from '$lib/stores/auth';
|
||||
import { page } from '$app/stores';
|
||||
import '../app.css';
|
||||
|
||||
let showDropdown = false;
|
||||
|
||||
// Close dropdown when route changes
|
||||
$: if ($page) {
|
||||
showDropdown = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
auth.init();
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
const handleClickOutside = (event) => {
|
||||
if (!event.target.closest('.user-menu')) {
|
||||
showDropdown = false;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
});
|
||||
|
||||
function toggleDropdown() {
|
||||
showDropdown = !showDropdown;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.nav {
|
||||
background: #111;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 0;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.user-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.user-avatar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--white);
|
||||
font-weight: 600;
|
||||
transition: border-color 0.2s;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-avatar-btn:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.user-avatar-btn img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 0.5rem);
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
min-width: 200px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dropdown-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.dropdown-username {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-username:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.dropdown-role {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: var(--white);
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(86, 29, 94, 0.2);
|
||||
}
|
||||
|
||||
.dropdown-divider {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dropdown-item.logout {
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="nav-container">
|
||||
<a href="/" class="nav-brand">Stream</a>
|
||||
|
||||
{#if !$auth.loading}
|
||||
{#if $isAuthenticated}
|
||||
<div class="user-menu">
|
||||
<button class="user-avatar-btn" on:click={toggleDropdown}>
|
||||
{#if $auth.user.avatarUrl}
|
||||
<img src={$auth.user.avatarUrl} alt={$auth.user.username} />
|
||||
{:else}
|
||||
{$auth.user.username.charAt(0).toUpperCase()}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div class="dropdown">
|
||||
<div class="dropdown-header">
|
||||
<a href="/profile/{$auth.user.username}" class="dropdown-username">
|
||||
{$auth.user.username}
|
||||
</a>
|
||||
<div class="dropdown-role">
|
||||
{#if $isAdmin}
|
||||
Admin
|
||||
{:else if $isStreamer}
|
||||
Streamer
|
||||
{:else}
|
||||
User
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="/settings" class="dropdown-item">
|
||||
Settings
|
||||
</a>
|
||||
{#if $isStreamer}
|
||||
<a href="/my-realms" class="dropdown-item">
|
||||
My Realms
|
||||
</a>
|
||||
{/if}
|
||||
{#if $isAdmin}
|
||||
<a href="/admin" class="dropdown-item">
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<button class="dropdown-item logout" on:click={() => auth.logout()}>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="nav-links">
|
||||
<a href="/login" class="nav-link">Login</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<slot />
|
||||
204
frontend/src/routes/+page.svelte
Normal file
204
frontend/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let streams = [];
|
||||
let interval;
|
||||
let loading = true;
|
||||
|
||||
async function loadStreams() {
|
||||
if (!browser) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/realms/live');
|
||||
if (res.ok) {
|
||||
streams = await res.json();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load streams:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadStreams();
|
||||
// Refresh every 10 seconds
|
||||
interval = setInterval(loadStreams, 10000);
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
background: linear-gradient(135deg, var(--primary), #8b3a92);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.stream-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stream-card {
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
text-decoration: none;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.stream-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(86, 29, 94, 0.3);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.stream-thumbnail {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
background: linear-gradient(135deg, #1a1a1a, #2a2a2a);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
background: #ff0000;
|
||||
color: white;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stream-info {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stream-info h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.stream-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.streamer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.streamer-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray);
|
||||
}
|
||||
|
||||
.viewer-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.viewer-count::before {
|
||||
content: '•';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ff0000;
|
||||
border-radius: 50%;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.no-streams {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.no-streams-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Live Streams</h1>
|
||||
<p>Watch your favorite streamers live</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<p>Loading streams...</p>
|
||||
</div>
|
||||
{:else if streams.length === 0}
|
||||
<div class="no-streams">
|
||||
<div class="no-streams-icon">📺</div>
|
||||
<h2>No streams live right now</h2>
|
||||
<p>Check back later or become a streamer yourself!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="stream-grid">
|
||||
{#each streams as stream}
|
||||
<a href={`/${stream.name}/live`} class="stream-card">
|
||||
<div class="stream-thumbnail">
|
||||
<div class="live-badge">LIVE</div>
|
||||
<span style="font-size: 3rem; opacity: 0.3;">🎮</span>
|
||||
</div>
|
||||
<div class="stream-info">
|
||||
<h3>{stream.name}</h3>
|
||||
<div class="stream-meta">
|
||||
<div class="streamer-info">
|
||||
{#if stream.avatarUrl}
|
||||
<img src={stream.avatarUrl} alt={stream.username} class="streamer-avatar" />
|
||||
{:else}
|
||||
<div class="streamer-avatar"></div>
|
||||
{/if}
|
||||
<span>{stream.username}</span>
|
||||
</div>
|
||||
<div class="viewer-count">
|
||||
{stream.viewerCount} {stream.viewerCount === 1 ? 'viewer' : 'viewers'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
669
frontend/src/routes/[realm]/live/+page.svelte
Normal file
669
frontend/src/routes/[realm]/live/+page.svelte
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { auth } from '$lib/stores/auth';
|
||||
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
// Import CSS that's safe for SSR
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||
import 'mdb-ui-kit/css/mdb.min.css';
|
||||
|
||||
// Browser-only imports
|
||||
let Hls;
|
||||
let OvenPlayer;
|
||||
|
||||
// Only import on client side
|
||||
if (typeof window !== 'undefined') {
|
||||
import('hls.js').then(module => {
|
||||
Hls = module.default;
|
||||
window.Hls = Hls;
|
||||
});
|
||||
|
||||
import('ovenplayer').then(module => {
|
||||
OvenPlayer = module.default;
|
||||
window.OvenPlayer = OvenPlayer;
|
||||
});
|
||||
}
|
||||
|
||||
const STREAM_PORT = import.meta.env.VITE_STREAM_PORT || '8088';
|
||||
|
||||
let player;
|
||||
let realm = null;
|
||||
let streamKey = '';
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let message = '';
|
||||
let isStreaming = false;
|
||||
let heartbeatInterval;
|
||||
let viewerToken = null;
|
||||
let statsInterval;
|
||||
|
||||
// Stats
|
||||
let stats = {
|
||||
connections: 0,
|
||||
bitrate: 0,
|
||||
resolution: 'N/A',
|
||||
codec: 'N/A',
|
||||
fps: 0,
|
||||
isLive: false
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const realmName = $page.params.realm;
|
||||
|
||||
// Load realm info
|
||||
await loadRealm(realmName);
|
||||
|
||||
if (!realm) {
|
||||
error = 'Realm not found';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get viewer token
|
||||
const tokenObtained = await getViewerToken();
|
||||
|
||||
if (!tokenObtained) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the actual stream key using the token
|
||||
const keyObtained = await getStreamKey();
|
||||
|
||||
if (!keyObtained) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for dependencies
|
||||
const checkDependencies = async () => {
|
||||
let attempts = 0;
|
||||
while ((!window.Hls || !window.OvenPlayer) && attempts < 20) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!window.Hls || !window.OvenPlayer) {
|
||||
console.error('Failed to load dependencies');
|
||||
error = 'Failed to load player dependencies';
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const depsLoaded = await checkDependencies();
|
||||
if (!depsLoaded) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize player after a short delay
|
||||
setTimeout(initializePlayer, 100);
|
||||
|
||||
// Start heartbeat
|
||||
startHeartbeat();
|
||||
|
||||
// Start stats polling
|
||||
startStatsPolling();
|
||||
|
||||
// Connect WebSocket
|
||||
connectWebSocket((data) => {
|
||||
if (data.type === 'stats_update' && data.stream_key === streamKey) {
|
||||
updateStatsFromData(data.stats);
|
||||
}
|
||||
});
|
||||
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (player) {
|
||||
player.remove();
|
||||
}
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
}
|
||||
if (statsInterval) {
|
||||
clearInterval(statsInterval);
|
||||
}
|
||||
disconnectWebSocket();
|
||||
});
|
||||
|
||||
async function loadRealm(realmName) {
|
||||
try {
|
||||
const response = await fetch(`/api/realms/by-name/${realmName}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
realm = data.realm;
|
||||
// Get the stream key from the database
|
||||
const keyResponse = await fetch(`/api/realms/${realm.id}`);
|
||||
if (keyResponse.ok && keyResponse.status !== 404) {
|
||||
const keyData = await keyResponse.json();
|
||||
if (keyData.success && keyData.realm && keyData.realm.streamKey) {
|
||||
streamKey = keyData.realm.streamKey;
|
||||
}
|
||||
} else {
|
||||
// If we can't get the key directly, we'll need to rely on the backend
|
||||
// to validate tokens against the realm
|
||||
streamKey = 'realm-' + realm.id;
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
error = 'Realm not found';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load realm:', e);
|
||||
error = 'Failed to load realm';
|
||||
}
|
||||
}
|
||||
|
||||
async function getViewerToken() {
|
||||
if (!realm) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/realms/${realm.id}/viewer-token`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
viewerToken = data.viewer_token;
|
||||
console.log('Viewer token obtained');
|
||||
|
||||
// Now we need to get the actual stream key for the player
|
||||
// This will be handled server-side via the token
|
||||
return true;
|
||||
} else {
|
||||
console.error('Failed to get viewer token');
|
||||
error = 'Failed to authenticate for stream';
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error getting viewer token:', e);
|
||||
error = 'Failed to authenticate for stream';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getStreamKey() {
|
||||
if (!realm || !viewerToken) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/realms/${realm.id}/stream-key`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
streamKey = data.streamKey;
|
||||
console.log('Stream key obtained');
|
||||
return true;
|
||||
} else {
|
||||
console.error('Failed to get stream key');
|
||||
error = 'Failed to get stream key';
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error getting stream key:', e);
|
||||
error = 'Failed to get stream key';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function startHeartbeat() {
|
||||
heartbeatInterval = setInterval(async () => {
|
||||
if (streamKey && viewerToken) {
|
||||
try {
|
||||
const response = await fetch(`/api/stream/heartbeat/${streamKey}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Heartbeat failed, getting new token');
|
||||
await getViewerToken();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Heartbeat error:', error);
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
function startStatsPolling() {
|
||||
statsInterval = setInterval(async () => {
|
||||
if (realm) {
|
||||
try {
|
||||
const response = await fetch(`/api/realms/${realm.id}/stats`);
|
||||
const data = await response.json();
|
||||
if (data.success && data.stats) {
|
||||
updateStatsFromData(data.stats);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function updateStatsFromData(data) {
|
||||
stats = {
|
||||
connections: data.connections || 0,
|
||||
bitrate: data.bitrate || 0,
|
||||
resolution: data.resolution || 'N/A',
|
||||
codec: data.codec || 'N/A',
|
||||
fps: data.fps || 0,
|
||||
isLive: data.is_live || false
|
||||
};
|
||||
|
||||
isStreaming = stats.isLive;
|
||||
|
||||
// Update viewer count in realm info if different
|
||||
if (realm && realm.viewerCount !== stats.connections) {
|
||||
realm.viewerCount = stats.connections;
|
||||
}
|
||||
}
|
||||
|
||||
function initializePlayer() {
|
||||
const playerElement = document.getElementById('player');
|
||||
if (!playerElement) {
|
||||
console.error('Player element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!viewerToken || !streamKey) {
|
||||
console.error('No viewer token or stream key, cannot initialize player');
|
||||
return;
|
||||
}
|
||||
|
||||
const sources = [];
|
||||
|
||||
if (streamKey) {
|
||||
// Add all sources
|
||||
sources.push(
|
||||
{
|
||||
type: 'webrtc',
|
||||
file: `ws://localhost:3333/app/${streamKey}`,
|
||||
label: 'WebRTC (Ultra Low Latency)'
|
||||
},
|
||||
{
|
||||
type: 'hls',
|
||||
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/llhls.m3u8`,
|
||||
label: 'LLHLS (Low Latency)'
|
||||
},
|
||||
{
|
||||
type: 'hls',
|
||||
file: `http://localhost:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8`,
|
||||
label: 'HLS (Standard)'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
autoStart: true,
|
||||
autoFallback: true,
|
||||
controls: true,
|
||||
showBigPlayButton: true,
|
||||
watermark: false,
|
||||
mute: false,
|
||||
aspectRatio: "16:9",
|
||||
sources: sources,
|
||||
webrtcConfig: {
|
||||
timeoutMaxRetry: 4,
|
||||
connectionTimeout: 10000
|
||||
},
|
||||
hlsConfig: {
|
||||
debug: false,
|
||||
enableWorker: true,
|
||||
lowLatencyMode: true,
|
||||
backBufferLength: 90,
|
||||
xhrSetup: function(xhr, url) {
|
||||
xhr.withCredentials = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
player = window.OvenPlayer.create('player', config);
|
||||
|
||||
player.on('error', (error) => {
|
||||
console.error('Player error:', error);
|
||||
isStreaming = false;
|
||||
|
||||
if (error.code === 403 || error.code === 401) {
|
||||
getViewerToken().then(() => {
|
||||
if (player) {
|
||||
player.remove();
|
||||
setTimeout(initializePlayer, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
player.on('stateChanged', (data) => {
|
||||
if (data.newstate === 'playing') {
|
||||
isStreaming = true;
|
||||
message = '';
|
||||
} else if (data.newstate === 'error' || data.newstate === 'idle') {
|
||||
if (!stats.isLive) {
|
||||
isStreaming = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
player.on('play', () => {
|
||||
isStreaming = true;
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to create player:', e);
|
||||
error = 'Failed to initialize player';
|
||||
}
|
||||
}
|
||||
|
||||
function formatBitrate(bitrate) {
|
||||
if (bitrate > 1000000) {
|
||||
return (bitrate / 1000000).toFixed(2) + ' Mbps';
|
||||
} else if (bitrate > 1000) {
|
||||
return (bitrate / 1000).toFixed(0) + ' Kbps';
|
||||
} else {
|
||||
return bitrate + ' bps';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Fix the background color issue */
|
||||
:global(body) {
|
||||
background: var(--black) !important;
|
||||
color: var(--white) !important;
|
||||
}
|
||||
|
||||
.stream-container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 2rem;
|
||||
background: var(--black);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stream-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.player-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.player-wrapper {
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.player-area {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dummy-player {
|
||||
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.player-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.stream-info-section {
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stream-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stream-header h1 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.streamer-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.streamer-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray);
|
||||
}
|
||||
|
||||
.streamer-name {
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.viewer-count {
|
||||
font-size: 0.9rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-section h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-indicator.active {
|
||||
background: rgba(40, 167, 69, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-indicator.inactive {
|
||||
background: rgba(220, 53, 69, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.stats-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.offline-message {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.offline-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
color: var(--gray);
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading-container">
|
||||
<p>Loading stream...</p>
|
||||
</div>
|
||||
{:else if error && !realm}
|
||||
<div class="error-container">
|
||||
<h1>Stream Not Found</h1>
|
||||
<p style="color: var(--gray); margin-top: 1rem;">{error}</p>
|
||||
<a href="/" class="btn" style="margin-top: 2rem;">Back to Home</a>
|
||||
</div>
|
||||
{:else if realm}
|
||||
<div class="stream-container">
|
||||
<div class="player-section">
|
||||
<div class="player-wrapper">
|
||||
<div class="player-area">
|
||||
<div class="dummy-player"></div>
|
||||
<div class="player-container">
|
||||
<div id="player"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stream-info-section">
|
||||
<div class="stream-header">
|
||||
<h1>{realm.name}</h1>
|
||||
<div class="streamer-info">
|
||||
{#if realm.avatarUrl}
|
||||
<img src={realm.avatarUrl} alt={realm.username} class="streamer-avatar" />
|
||||
{:else}
|
||||
<div class="streamer-avatar"></div>
|
||||
{/if}
|
||||
<div>
|
||||
<div class="streamer-name">{realm.username}</div>
|
||||
<div class="viewer-count">
|
||||
{realm.viewerCount} {realm.viewerCount === 1 ? 'viewer' : 'viewers'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="stats-section">
|
||||
<h3>Stream Stats</h3>
|
||||
|
||||
<div class="status-indicator" class:active={stats.isLive} class:inactive={!stats.isLive}>
|
||||
{#if stats.isLive}
|
||||
<span>●</span> Live
|
||||
{:else}
|
||||
<span>●</span> Offline
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if stats.isLive}
|
||||
<div class="stats-list">
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Viewers</span>
|
||||
<span class="stat-value">{stats.connections}</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Bitrate</span>
|
||||
<span class="stat-value">{formatBitrate(stats.bitrate)}</span>
|
||||
</div>
|
||||
{#if stats.resolution !== 'N/A'}
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Resolution</span>
|
||||
<span class="stat-value">{stats.resolution}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.fps > 0}
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Frame Rate</span>
|
||||
<span class="stat-value">{stats.fps.toFixed(1)} fps</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if stats.codec}
|
||||
<div class="stat-item">
|
||||
<span class="stat-label">Codec</span>
|
||||
<span class="stat-value">{stats.codec}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="offline-message">
|
||||
<div class="offline-icon">📺</div>
|
||||
<p>Stream is currently offline</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if message}
|
||||
<div class="message" style="position: fixed; top: 2rem; right: 2rem; padding: 1rem 2rem; background: var(--primary); color: white; border-radius: 4px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); z-index: 1000;">
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
385
frontend/src/routes/admin/+page.svelte
Normal file
385
frontend/src/routes/admin/+page.svelte
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { auth, isAuthenticated, isAdmin } from '$lib/stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let users = [];
|
||||
let streams = [];
|
||||
let loading = true;
|
||||
let message = '';
|
||||
let error = '';
|
||||
let activeTab = 'users';
|
||||
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$isAdmin) {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
await Promise.all([loadUsers(), loadStreams()]);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/users', {
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
users = data.users;
|
||||
} else {
|
||||
error = 'Failed to load users';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error loading users';
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStreams() {
|
||||
try {
|
||||
const response = await fetch('/api/admin/streams', {
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
streams = data.streams || [];
|
||||
} else {
|
||||
error = 'Failed to load streams';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error loading streams';
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteToStreamer(userId) {
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}/promote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
message = 'User promoted to streamer';
|
||||
await loadUsers();
|
||||
} else {
|
||||
error = 'Failed to promote user';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error promoting user';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => { message = ''; error = ''; }, 3000);
|
||||
}
|
||||
|
||||
async function demoteFromStreamer(userId) {
|
||||
if (!confirm('Remove streamer privileges from this user? Their realms will remain but they cannot create new ones.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/users/${userId}/demote`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
message = 'User demoted from streamer';
|
||||
await loadUsers();
|
||||
} else {
|
||||
error = 'Failed to demote user';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error demoting user';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => { message = ''; error = ''; }, 3000);
|
||||
}
|
||||
|
||||
async function disconnectStream(streamKey) {
|
||||
if (!confirm(`Disconnect stream ${streamKey}?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/streams/${streamKey}/disconnect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
message = 'Stream disconnected';
|
||||
await loadStreams();
|
||||
} else {
|
||||
error = 'Failed to disconnect stream';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error disconnecting stream';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => { message = ''; error = ''; }, 3000);
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.admin-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--gray);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.data-table table {
|
||||
width: 100%;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.stream-key-cell {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.role-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="admin-header">
|
||||
<h1>Admin Dashboard</h1>
|
||||
<button on:click={loadData} disabled={loading}>
|
||||
{loading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
<div class="success" style="margin-bottom: 1rem;">{message}</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="error" style="margin-bottom: 1rem;">{error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="stats-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{users.length}</div>
|
||||
<div class="stat-label">Total Users</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{users.filter(u => u.isAdmin).length}</div>
|
||||
<div class="stat-label">Admins</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{users.filter(u => u.isStreamer).length}</div>
|
||||
<div class="stat-label">Streamers</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value">{streams.length}</div>
|
||||
<div class="stat-label">Active Streams</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-container">
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'users'}
|
||||
on:click={() => activeTab = 'users'}
|
||||
>
|
||||
Users
|
||||
</button>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'streams'}
|
||||
on:click={() => activeTab = 'streams'}
|
||||
>
|
||||
Active Streams
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{:else if activeTab === 'users'}
|
||||
<div class="card">
|
||||
<div class="data-table">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Roles</th>
|
||||
<th>Realms</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user}
|
||||
<tr>
|
||||
<td>{user.id}</td>
|
||||
<td>
|
||||
<a href="/profile/{user.username}" style="color: var(--primary);">
|
||||
{user.username}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="role-badges">
|
||||
{#if user.isAdmin}
|
||||
<span class="badge badge-admin">Admin</span>
|
||||
{/if}
|
||||
{#if user.isStreamer}
|
||||
<span class="badge" style="background: #28a745;">Streamer</span>
|
||||
{/if}
|
||||
{#if !user.isAdmin && !user.isStreamer}
|
||||
<span class="badge" style="background: #6c757d;">User</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>{user.realmCount || 0}</td>
|
||||
<td>{formatDate(user.createdAt)}</td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<a href="/profile/{user.username}" class="btn btn-secondary action-btn">
|
||||
View
|
||||
</a>
|
||||
{#if !user.isAdmin}
|
||||
{#if !user.isStreamer}
|
||||
<button
|
||||
class="btn action-btn"
|
||||
style="background: #28a745;"
|
||||
on:click={() => promoteToStreamer(user.id)}
|
||||
>
|
||||
Make Streamer
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-danger action-btn"
|
||||
on:click={() => demoteFromStreamer(user.id)}
|
||||
>
|
||||
Remove Streamer
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'streams'}
|
||||
<div class="card">
|
||||
{#if streams.length > 0}
|
||||
<div class="data-table">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Realm</th>
|
||||
<th>Streamer</th>
|
||||
<th>Stream Key</th>
|
||||
<th>Viewers</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each streams as stream}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/{stream.name}/live" style="color: var(--primary);">
|
||||
{stream.name}
|
||||
</a>
|
||||
</td>
|
||||
<td>{stream.username}</td>
|
||||
<td class="stream-key-cell" title={stream.streamKey}>
|
||||
{stream.streamKey}
|
||||
</td>
|
||||
<td>{stream.viewerCount}</td>
|
||||
<td>
|
||||
<button
|
||||
class="btn btn-danger action-btn"
|
||||
on:click={() => disconnectStream(stream.streamKey)}
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p style="color: var(--gray);">No active streams</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
534
frontend/src/routes/login/+page.svelte
Normal file
534
frontend/src/routes/login/+page.svelte
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { auth, isAuthenticated } from '$lib/stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as pgp from '$lib/pgp';
|
||||
import '../../app.css';
|
||||
|
||||
let mode = 'login';
|
||||
let username = '';
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
let error = '';
|
||||
let loading = false;
|
||||
let pgpLoading = false;
|
||||
|
||||
// PGP login
|
||||
let pgpChallenge = '';
|
||||
let pgpPublicKey = '';
|
||||
let pgpSignature = '';
|
||||
|
||||
// For displaying generated keys
|
||||
let showGeneratedKeys = false;
|
||||
let generatedPrivateKey = '';
|
||||
let generatedPublicKey = '';
|
||||
|
||||
// Show PGP command example
|
||||
let showPgpExample = false;
|
||||
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
if ($isAuthenticated) {
|
||||
goto('/');
|
||||
}
|
||||
});
|
||||
|
||||
function validatePassword(pass) {
|
||||
if (pass.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
if (!/[0-9]/.test(pass)) {
|
||||
return 'Password must contain at least one number';
|
||||
}
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(pass)) {
|
||||
return 'Password must contain at least one symbol';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function handleLogin() {
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
const result = await auth.login({ username, password });
|
||||
|
||||
if (!result.success) {
|
||||
error = result.error;
|
||||
|
||||
// If it's a PGP-only error, automatically switch to PGP login
|
||||
if (error && error.includes('PGP-only login enabled')) {
|
||||
// Clear the error and initiate PGP login
|
||||
error = '';
|
||||
loading = false;
|
||||
await initiatePgpLogin();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function handleRegister() {
|
||||
error = '';
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
error = 'Passwords do not match';
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordError = validatePassword(password);
|
||||
if (passwordError) {
|
||||
error = passwordError;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
// Generate PGP key pair
|
||||
const keyPair = await pgp.generateKeyPair(username);
|
||||
|
||||
const result = await auth.register({
|
||||
username,
|
||||
password,
|
||||
publicKey: keyPair.publicKey,
|
||||
fingerprint: keyPair.fingerprint
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Store private key locally
|
||||
pgp.storePrivateKey(keyPair.privateKey);
|
||||
|
||||
// Show keys
|
||||
generatedPrivateKey = keyPair.privateKey;
|
||||
generatedPublicKey = keyPair.publicKey;
|
||||
showGeneratedKeys = true;
|
||||
} else {
|
||||
error = result.error;
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Failed to generate PGP keys';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
async function initiatePgpLogin() {
|
||||
error = '';
|
||||
pgpLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/pgp-challenge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
pgpChallenge = data.challenge;
|
||||
pgpPublicKey = data.publicKey;
|
||||
|
||||
// Clear the signature field
|
||||
pgpSignature = '';
|
||||
} else {
|
||||
error = data.error || 'User not found or PGP not enabled';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Failed to initiate PGP login';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
pgpLoading = false;
|
||||
}
|
||||
|
||||
async function handlePgpLogin() {
|
||||
error = '';
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
if (!pgpSignature) {
|
||||
error = 'Please provide the signed message';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await auth.loginWithPgp(username, pgpSignature, pgpChallenge);
|
||||
|
||||
if (!result.success) {
|
||||
error = result.error;
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Failed to verify signature';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
function resetPgpLogin() {
|
||||
pgpChallenge = '';
|
||||
pgpPublicKey = '';
|
||||
pgpSignature = '';
|
||||
error = '';
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
alert('Copied to clipboard!');
|
||||
}
|
||||
|
||||
function downloadKey(content, filename) {
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function proceedAfterKeys() {
|
||||
// Auto-login after registration
|
||||
loading = true;
|
||||
const result = await auth.login({ username, password });
|
||||
if (result.success) {
|
||||
goto('/');
|
||||
} else {
|
||||
showGeneratedKeys = false;
|
||||
mode = 'login';
|
||||
error = 'Registration successful. Please login.';
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-container">
|
||||
{#if showGeneratedKeys}
|
||||
<h1>Your PGP Keys</h1>
|
||||
<p style="color: var(--error); margin-bottom: 1rem;">
|
||||
<strong>Important:</strong> Save your private key securely. You will need it to login with PGP.
|
||||
</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Public Key</label>
|
||||
<textarea
|
||||
readonly
|
||||
rows="10"
|
||||
value={generatedPublicKey}
|
||||
style="font-family: monospace; font-size: 0.8rem;"
|
||||
/>
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||
<button type="button" on:click={() => copyToClipboard(generatedPublicKey)}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPublicKey, `${username}-public-key.asc`)}>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Private Key</label>
|
||||
<textarea
|
||||
readonly
|
||||
rows="10"
|
||||
value={generatedPrivateKey}
|
||||
style="font-family: monospace; font-size: 0.8rem;"
|
||||
/>
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||
<button type="button" on:click={() => copyToClipboard(generatedPrivateKey)}>
|
||||
Copy
|
||||
</button>
|
||||
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${username}-private-key.asc`)}>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-block" on:click={proceedAfterKeys} disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Continue'}
|
||||
</button>
|
||||
{:else}
|
||||
<h1>{mode === 'login' ? 'Login' : 'Register'}</h1>
|
||||
|
||||
{#if mode === 'login' && !pgpChallenge}
|
||||
<form on:submit|preventDefault={handleLogin}>
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
bind:value={username}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn-block" disabled={loading}>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
|
||||
<div style="margin: 1rem 0; text-align: center; color: var(--gray);">
|
||||
OR
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-block"
|
||||
on:click={initiatePgpLogin}
|
||||
disabled={loading || pgpLoading || !username}
|
||||
>
|
||||
{pgpLoading ? 'Loading...' : 'Login with PGP'}
|
||||
</button>
|
||||
</form>
|
||||
{:else if mode === 'login' && pgpChallenge}
|
||||
<form on:submit|preventDefault={handlePgpLogin}>
|
||||
<h3 style="margin-bottom: 1rem;">PGP Authentication</h3>
|
||||
|
||||
<div class="instruction-box">
|
||||
<p><strong>Step 1:</strong> Copy this challenge text:</p>
|
||||
<div class="pgp-key" style="margin: 0.5rem 0;">
|
||||
{pgpChallenge}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
style="margin-bottom: 1rem;"
|
||||
on:click={() => copyToClipboard(pgpChallenge)}
|
||||
>
|
||||
Copy Challenge
|
||||
</button>
|
||||
|
||||
<p><strong>Step 2:</strong> Sign it with your private key using GPG or another PGP tool</p>
|
||||
<p style="color: var(--gray); font-size: 0.85rem; margin-top: 0.5rem;">
|
||||
Note: For security, we never handle your private keys. All signing must be done on your device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-link"
|
||||
style="margin-bottom: 1rem; font-size: 0.9rem;"
|
||||
on:click={() => showPgpExample = !showPgpExample}
|
||||
>
|
||||
{showPgpExample ? 'Hide' : 'Show'} how to sign
|
||||
</button>
|
||||
|
||||
{#if showPgpExample}
|
||||
<div class="example-section">
|
||||
<h4>Using GPG command line:</h4>
|
||||
<pre class="command-example">
|
||||
# Save the challenge to a file
|
||||
echo "{pgpChallenge}" > challenge.txt
|
||||
|
||||
# Sign with your private key
|
||||
gpg --armor --detach-sign challenge.txt
|
||||
|
||||
# This creates challenge.txt.asc with the signature
|
||||
# Copy the entire contents including BEGIN/END lines</pre>
|
||||
|
||||
<h4>Using Kleopatra (Windows):</h4>
|
||||
<ol style="font-size: 0.9rem; margin: 0.5rem 0;">
|
||||
<li>Save the challenge text to a file</li>
|
||||
<li>Right-click the file → Sign</li>
|
||||
<li>Select your key and choose "Create detached signature"</li>
|
||||
<li>Open the .asc file and copy its contents</li>
|
||||
</ol>
|
||||
|
||||
<h4>Using GPG Suite (Mac):</h4>
|
||||
<ol style="font-size: 0.9rem; margin: 0.5rem 0;">
|
||||
<li>Save the challenge text to a file</li>
|
||||
<li>Right-click the file → Services → OpenPGP: Sign File</li>
|
||||
<li>Choose "Create Detached Signature"</li>
|
||||
<li>Open the .sig file and copy its contents</li>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div style="margin: 1.5rem 0; text-align: center; color: var(--gray);">
|
||||
<strong>Step 3:</strong> Paste your signature below
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="signature">Signed Message</label>
|
||||
<textarea
|
||||
id="signature"
|
||||
bind:value={pgpSignature}
|
||||
rows="10"
|
||||
required
|
||||
disabled={loading}
|
||||
placeholder="-----BEGIN PGP SIGNATURE-----
|
||||
Version: GnuPG v2
|
||||
|
||||
iQEcBAABCAAGBQJe...
|
||||
...
|
||||
-----END PGP SIGNATURE-----"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn-block" disabled={loading || !pgpSignature}>
|
||||
{loading ? 'Verifying...' : 'Verify and Login'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-block"
|
||||
style="margin-top: 1rem;"
|
||||
on:click={resetPgpLogin}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={handleRegister}>
|
||||
<div class="form-group">
|
||||
<label for="reg-username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="reg-username"
|
||||
bind:value={username}
|
||||
required
|
||||
disabled={loading}
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
title="Letters, numbers, and underscores only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="reg-password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="reg-password"
|
||||
bind:value={password}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
<small style="color: var(--gray);">
|
||||
Must be 8+ characters with at least one number and symbol
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm-password">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
bind:value={confirmPassword}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="btn-block" disabled={loading}>
|
||||
{loading ? 'Creating account...' : 'Register'}
|
||||
</button>
|
||||
|
||||
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--gray);">
|
||||
A PGP key pair will be generated for your account. You'll be able to save both keys after registration.
|
||||
</p>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div style="margin-top: 2rem; text-align: center;">
|
||||
{#if mode === 'login'}
|
||||
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; }}>
|
||||
Need an account? Register
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; }}>
|
||||
Already have an account? Login
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.instruction-box {
|
||||
background: rgba(86, 29, 94, 0.05);
|
||||
border: 1px solid rgba(86, 29, 94, 0.2);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.instruction-box p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.example-section {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.example-section h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.command-example {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
}
|
||||
|
||||
.example-section ol {
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.example-section ol li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
504
frontend/src/routes/my-realms/+page.svelte
Normal file
504
frontend/src/routes/my-realms/+page.svelte
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { auth, isAuthenticated, isStreamer } from '$lib/stores/auth';
|
||||
import { goto } from '$app/navigation';
|
||||
import { connectWebSocket, disconnectWebSocket } from '$lib/websocket';
|
||||
|
||||
let realms = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let message = '';
|
||||
let showCreateModal = false;
|
||||
let statsInterval;
|
||||
|
||||
// Create form
|
||||
let newRealmName = '';
|
||||
|
||||
onMount(async () => {
|
||||
await auth.init();
|
||||
|
||||
if (!$isAuthenticated) {
|
||||
goto('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$isStreamer) {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadRealms();
|
||||
|
||||
// Start polling for stats
|
||||
statsInterval = setInterval(async () => {
|
||||
for (const realm of realms) {
|
||||
if (realm.isLive) {
|
||||
await updateRealmStats(realm.id);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Connect WebSocket for real-time updates
|
||||
connectWebSocket((data) => {
|
||||
if (data.type === 'stats_update') {
|
||||
const realm = realms.find(r => r.streamKey === data.stream_key);
|
||||
if (realm && data.stats) {
|
||||
realm.isLive = data.stats.is_live;
|
||||
realm.viewerCount = data.stats.connections || 0;
|
||||
realm.stats = data.stats;
|
||||
realms = realms;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (statsInterval) {
|
||||
clearInterval(statsInterval);
|
||||
}
|
||||
disconnectWebSocket();
|
||||
});
|
||||
|
||||
async function loadRealms() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/realms', {
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
realms = data.realms;
|
||||
|
||||
// Load stats for each realm
|
||||
for (const realm of realms) {
|
||||
await updateRealmStats(realm.id);
|
||||
}
|
||||
} else {
|
||||
error = 'Failed to load realms';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error loading realms';
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateRealmStats(realmId) {
|
||||
try {
|
||||
const response = await fetch(`/api/realms/${realmId}/stats`, {
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const realm = realms.find(r => r.id === realmId);
|
||||
if (realm && data.success && data.stats) {
|
||||
realm.isLive = data.stats.is_live;
|
||||
realm.viewerCount = data.stats.connections || 0;
|
||||
realm.stats = data.stats;
|
||||
realms = realms;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch stats:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRealm() {
|
||||
error = '';
|
||||
|
||||
if (!validateRealmName(newRealmName)) {
|
||||
error = 'Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/realms', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${$auth.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ name: newRealmName })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
message = 'Realm created successfully';
|
||||
showCreateModal = false;
|
||||
newRealmName = '';
|
||||
await loadRealms();
|
||||
} else {
|
||||
error = data.error || 'Failed to create realm';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error creating realm';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => { message = ''; }, 3000);
|
||||
}
|
||||
|
||||
async function deleteRealm(realm) {
|
||||
if (!confirm(`Delete realm "${realm.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/realms/${realm.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
message = 'Realm deleted successfully';
|
||||
await loadRealms();
|
||||
} else {
|
||||
error = 'Failed to delete realm';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error deleting realm';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => { message = ''; error = ''; }, 3000);
|
||||
}
|
||||
|
||||
async function regenerateKey(realm) {
|
||||
if (!confirm('Regenerate stream key? This will disconnect any active streams.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/realms/${realm.id}/regenerate-key`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
message = 'Stream key regenerated successfully';
|
||||
await loadRealms();
|
||||
} else {
|
||||
error = 'Failed to regenerate stream key';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error regenerating stream key';
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
setTimeout(() => { message = ''; error = ''; }, 3000);
|
||||
}
|
||||
|
||||
function validateRealmName(name) {
|
||||
return /^[a-z0-9-]{3,30}$/.test(name);
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
message = 'Copied to clipboard!';
|
||||
setTimeout(() => message = '', 2000);
|
||||
}
|
||||
|
||||
function formatBitrate(bitrate) {
|
||||
if (bitrate > 1000000) {
|
||||
return (bitrate / 1000000).toFixed(2) + ' Mbps';
|
||||
} else if (bitrate > 1000) {
|
||||
return (bitrate / 1000).toFixed(0) + ' Kbps';
|
||||
} else {
|
||||
return bitrate + ' bps';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.realms-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.realms-grid {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.realm-card {
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.realm-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.realm-title h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.realm-url {
|
||||
color: var(--gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.realm-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stream-info {
|
||||
background: rgba(86, 29, 94, 0.1);
|
||||
border: 1px solid rgba(86, 29, 94, 0.3);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stream-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stream-info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.stream-info-label {
|
||||
font-weight: 600;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.stream-key {
|
||||
font-family: monospace;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-indicator.active {
|
||||
background: rgba(40, 167, 69, 0.2);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-indicator.inactive {
|
||||
background: rgba(220, 53, 69, 0.2);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.stats-mini {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-mini {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-mini-label {
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.no-realms {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
.no-realms-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
<div class="realms-header">
|
||||
<h1>My Realms</h1>
|
||||
<button
|
||||
class="btn"
|
||||
on:click={() => showCreateModal = true}
|
||||
disabled={realms.length >= 5}
|
||||
>
|
||||
Create Realm
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
<div class="success" style="margin-bottom: 1rem;">{message}</div>
|
||||
{/if}
|
||||
|
||||
{#if error && !showCreateModal}
|
||||
<div class="error" style="margin-bottom: 1rem;">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p>Loading realms...</p>
|
||||
{:else if realms.length === 0}
|
||||
<div class="no-realms">
|
||||
<div class="no-realms-icon">🏰</div>
|
||||
<h2>No realms yet</h2>
|
||||
<p>Create your first realm to start streaming!</p>
|
||||
<button
|
||||
class="btn"
|
||||
style="margin-top: 1rem;"
|
||||
on:click={() => showCreateModal = true}
|
||||
>
|
||||
Create Your First Realm
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="realms-grid">
|
||||
{#each realms as realm}
|
||||
<div class="realm-card">
|
||||
<div class="realm-header">
|
||||
<div class="realm-title">
|
||||
<h3>{realm.name}</h3>
|
||||
<p class="realm-url">/{realm.name}/live</p>
|
||||
</div>
|
||||
<div class="realm-actions">
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
style="padding: 0.5rem 1rem;"
|
||||
on:click={() => deleteRealm(realm)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<div class="status-indicator" class:active={realm.isLive} class:inactive={!realm.isLive}>
|
||||
{#if realm.isLive}
|
||||
<span>●</span> Live
|
||||
{:else}
|
||||
<span>●</span> Offline
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if realm.isLive && realm.stats}
|
||||
<div class="stats-mini">
|
||||
<div class="stat-mini">
|
||||
<span class="stat-mini-label">Viewers:</span>
|
||||
<span>{realm.viewerCount}</span>
|
||||
</div>
|
||||
<div class="stat-mini">
|
||||
<span class="stat-mini-label">Bitrate:</span>
|
||||
<span>{formatBitrate(realm.stats.bitrate)}</span>
|
||||
</div>
|
||||
{#if realm.stats.resolution !== 'N/A'}
|
||||
<div class="stat-mini">
|
||||
<span class="stat-mini-label">Resolution:</span>
|
||||
<span>{realm.stats.resolution}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="stream-info">
|
||||
<div class="stream-info-row">
|
||||
<span class="stream-info-label">Stream Key:</span>
|
||||
<span class="stream-key">{realm.streamKey}</span>
|
||||
<button on:click={() => copyToClipboard(realm.streamKey)}>Copy</button>
|
||||
</div>
|
||||
<div class="stream-info-row">
|
||||
<span class="stream-info-label">RTMP URL:</span>
|
||||
<span class="stream-key">rtmp://localhost:1935/app/{realm.streamKey}</span>
|
||||
<button on:click={() => copyToClipboard(`rtmp://localhost:1935/app/${realm.streamKey}`)}>Copy</button>
|
||||
</div>
|
||||
<div class="stream-info-row">
|
||||
<span class="stream-info-label">SRT URL:</span>
|
||||
<span class="stream-key">srt://localhost:9999?streamid={encodeURIComponent(`srt://localhost:9999/app/${realm.streamKey}`)}</span>
|
||||
<button on:click={() => copyToClipboard(`srt://localhost:9999?streamid=${encodeURIComponent(`srt://localhost:9999/app/${realm.streamKey}`)}`)}>Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
style="width: 100%;"
|
||||
on:click={() => regenerateKey(realm)}
|
||||
>
|
||||
Regenerate Stream Key
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Create Realm Modal -->
|
||||
{#if showCreateModal}
|
||||
<div class="modal" on:click={() => showCreateModal = false}>
|
||||
<div class="modal-content" on:click|stopPropagation>
|
||||
<div class="modal-header">
|
||||
<h2>Create New Realm</h2>
|
||||
<button class="modal-close" on:click={() => showCreateModal = false}>×</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error" style="margin-bottom: 1rem;">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={createRealm}>
|
||||
<div class="form-group">
|
||||
<label for="realm-name">Realm Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="realm-name"
|
||||
bind:value={newRealmName}
|
||||
required
|
||||
pattern="[a-z0-9-]{3,30}"
|
||||
placeholder="my-awesome-realm"
|
||||
/>
|
||||
<p class="form-hint">
|
||||
3-30 characters, lowercase letters, numbers, and hyphens only.
|
||||
This will be your realm's URL: /{newRealmName || 'realm-name'}/live
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-block">Create Realm</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
448
frontend/src/routes/profile/[username]/+page.svelte
Normal file
448
frontend/src/routes/profile/[username]/+page.svelte
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { auth, isAuthenticated } from '$lib/stores/auth';
|
||||
|
||||
let profile = null;
|
||||
let pgpKeys = [];
|
||||
let loading = true;
|
||||
let error = '';
|
||||
let isOwnProfile = false;
|
||||
let activeTab = 'bio';
|
||||
let expandedKeys = {}; // Track which keys are expanded
|
||||
|
||||
onMount(async () => {
|
||||
// No authentication required - profile is public
|
||||
const username = $page.params.username;
|
||||
|
||||
// Check if viewing own profile (only if authenticated)
|
||||
if ($isAuthenticated && $auth.user) {
|
||||
isOwnProfile = $auth.user.username === username;
|
||||
}
|
||||
|
||||
await loadProfile(username);
|
||||
await loadPgpKeys(username);
|
||||
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function loadProfile(username) {
|
||||
try {
|
||||
// Public endpoint - no auth header needed
|
||||
const response = await fetch(`/api/users/${username}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
profile = data.profile;
|
||||
|
||||
// Check if the profile has pgpOnlyEnabledAt set
|
||||
if (profile && profile.pgpOnlyEnabledAt) {
|
||||
console.log('Profile has PGP-only enabled at:', profile.pgpOnlyEnabledAt);
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
error = 'User not found';
|
||||
} else {
|
||||
error = 'Failed to load profile';
|
||||
}
|
||||
} catch (e) {
|
||||
error = 'Error loading profile';
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPgpKeys(username) {
|
||||
try {
|
||||
// Public endpoint - no auth header needed
|
||||
const response = await fetch(`/api/users/${username}/pgp-keys`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
pgpKeys = data.keys;
|
||||
// Initialize all keys as collapsed
|
||||
pgpKeys.forEach(key => {
|
||||
expandedKeys[key.fingerprint] = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load PGP keys:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
alert('Copied to clipboard!');
|
||||
}
|
||||
|
||||
function toggleKey(fingerprint) {
|
||||
expandedKeys[fingerprint] = !expandedKeys[fingerprint];
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
timeZoneName: 'short'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: var(--gray);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.profile-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.profile-info h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.member-since {
|
||||
color: var(--gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pgp-only-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: rgba(40, 167, 69, 0.2);
|
||||
color: var(--success);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-nav {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 1rem 2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--gray);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab-button:hover {
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.bio-section {
|
||||
padding: 1.5rem;
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.bio-section h3 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.no-bio {
|
||||
color: var(--gray);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.pgp-section {
|
||||
padding: 1.5rem;
|
||||
background: #111;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pgp-status-info {
|
||||
background: rgba(86, 29, 94, 0.1);
|
||||
border: 1px solid rgba(86, 29, 94, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.pgp-status-info.pgp-only {
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
border-color: rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.pgp-status-info p {
|
||||
margin: 0 0 0.5rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.pgp-status-info p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pgp-status-info .icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.pgp-key-item {
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pgp-key-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pgp-key-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pgp-key-header:hover {
|
||||
background: rgba(86, 29, 94, 0.1);
|
||||
margin: -0.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pgp-key-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fingerprint-display {
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.key-date {
|
||||
color: var(--gray);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
color: var(--gray);
|
||||
transition: transform 0.2s;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.expand-icon.expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.pgp-key-content {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.pgp-key {
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.no-keys {
|
||||
color: var(--gray);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.pgp-enabled-date {
|
||||
font-size: 0.85rem;
|
||||
color: var(--gray);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container">
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{:else if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else if profile}
|
||||
<div class="profile-header">
|
||||
<div class="profile-avatar">
|
||||
{#if profile.avatarUrl}
|
||||
<img src={profile.avatarUrl} alt="{profile.username}" />
|
||||
{:else}
|
||||
{profile.username.charAt(0).toUpperCase()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="profile-info">
|
||||
<h1>{profile.username}</h1>
|
||||
<p class="member-since">
|
||||
Member since {new Date(profile.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
{#if profile.isPgpOnly}
|
||||
<div class="pgp-only-badge">
|
||||
<span>🔒</span>
|
||||
<span>PGP-Only Authentication</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-nav">
|
||||
<button
|
||||
class="tab-button"
|
||||
class:active={activeTab === 'bio'}
|
||||
on:click={() => activeTab = 'bio'}
|
||||
>
|
||||
Bio
|
||||
</button>
|
||||
<button
|
||||
class="tab-button"
|
||||
class:active={activeTab === 'pgp'}
|
||||
on:click={() => activeTab = 'pgp'}
|
||||
>
|
||||
PGP Keys ({pgpKeys.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'bio'}
|
||||
<div class="bio-section">
|
||||
<h3>About</h3>
|
||||
{#if profile.bio}
|
||||
<p>{profile.bio}</p>
|
||||
{:else}
|
||||
<p class="no-bio">No bio yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === 'pgp'}
|
||||
<div class="pgp-section">
|
||||
<h3>PGP Keys</h3>
|
||||
|
||||
{#if profile.isPgpOnly}
|
||||
<div class="pgp-status-info pgp-only">
|
||||
<p>
|
||||
<span class="icon">✔</span>
|
||||
<strong>PGP-Only Authentication Enabled</strong>
|
||||
</p>
|
||||
{#if profile.pgpOnlyEnabledAt}
|
||||
<p class="pgp-enabled-date">
|
||||
Enabled: {formatDateTime(profile.pgpOnlyEnabledAt)}
|
||||
</p>
|
||||
{/if}
|
||||
<p style="font-size: 0.85rem;">
|
||||
This user requires PGP signature verification to login.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pgp-status-info">
|
||||
<p>
|
||||
<span class="icon">🔑</span>
|
||||
Standard authentication (password + optional PGP)
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if pgpKeys.length > 0}
|
||||
{#each pgpKeys as key}
|
||||
<div class="pgp-key-item">
|
||||
<div
|
||||
class="pgp-key-header"
|
||||
on:click={() => toggleKey(key.fingerprint)}
|
||||
on:keypress={(e) => e.key === 'Enter' && toggleKey(key.fingerprint)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<div class="pgp-key-info">
|
||||
<div class="fingerprint-display">{key.fingerprint}</div>
|
||||
<div class="key-date">Added {new Date(key.createdAt).toLocaleDateString()}</div>
|
||||
</div>
|
||||
<span class="expand-icon" class:expanded={expandedKeys[key.fingerprint]}>
|
||||
▶
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if expandedKeys[key.fingerprint]}
|
||||
<div class="pgp-key-content">
|
||||
<div class="pgp-key">
|
||||
{key.publicKey}
|
||||
</div>
|
||||
<button
|
||||
class="copy-button"
|
||||
on:click={() => copyToClipboard(key.publicKey)}
|
||||
>
|
||||
Copy Public Key
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="no-keys">
|
||||
<p>No PGP keys added</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
1173
frontend/src/routes/settings/+page.svelte
Normal file
1173
frontend/src/routes/settings/+page.svelte
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue