mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
Wireframe background, unified loading, admin format enforcement
Wireframe background: - Canvas-based constellation animation — 45 floating nodes connected by proximity lines, subtle pulsing glow on select nodes - Blue primary + orange accent line colors match cyberpunk palette - Pauses on tab hidden, respects devicePixelRatio, ~0% CPU idle - Only renders when cyberpunk theme is active (v-if on theme) - Replaces CSS-only diagonal lines/pulse (removed from cyberpunk.css) Unified URL analysis: - Merged 'Checking URL...' and 'Extracting available formats...' into a single loading state with rotating messages: 'Peeking at the URL', 'Interrogating the server', 'Decoding the matrix', etc. - Both fetches run in parallel via Promise.all, single spinner shown - Phase messages rotate every 1.5s during analysis Admin format enforcement: - Backend PUT /admin/settings now accepts default_video_format and default_audio_format fields with validation - Stored in settings_overrides alongside welcome_message - UrlInput reads admin defaults from config store — Auto label shows 'Auto (.mp3)' etc. when admin has set a default - effectiveOutputFormat computed resolves admin default when user selects Auto, sends the resolved format to the backend
This commit is contained in:
parent
44eb8c758a
commit
635da2be82
5 changed files with 254 additions and 79 deletions
|
|
@ -168,6 +168,8 @@ async def update_settings(
|
||||||
|
|
||||||
Accepts a JSON body with optional fields:
|
Accepts a JSON body with optional fields:
|
||||||
- welcome_message: str
|
- welcome_message: str
|
||||||
|
- default_video_format: str (auto, mp4, webm)
|
||||||
|
- default_audio_format: str (auto, mp3, m4a, flac, wav, opus)
|
||||||
"""
|
"""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|
||||||
|
|
@ -188,4 +190,21 @@ async def update_settings(
|
||||||
updated.append("welcome_message")
|
updated.append("welcome_message")
|
||||||
logger.info("Admin updated welcome_message to: %s", msg[:80])
|
logger.info("Admin updated welcome_message to: %s", msg[:80])
|
||||||
|
|
||||||
|
valid_video_formats = {"auto", "mp4", "webm"}
|
||||||
|
valid_audio_formats = {"auto", "mp3", "m4a", "flac", "wav", "opus"}
|
||||||
|
|
||||||
|
if "default_video_format" in body:
|
||||||
|
fmt = body["default_video_format"]
|
||||||
|
if fmt in valid_video_formats:
|
||||||
|
request.app.state.settings_overrides["default_video_format"] = fmt
|
||||||
|
updated.append("default_video_format")
|
||||||
|
logger.info("Admin updated default_video_format to: %s", fmt)
|
||||||
|
|
||||||
|
if "default_audio_format" in body:
|
||||||
|
fmt = body["default_audio_format"]
|
||||||
|
if fmt in valid_audio_formats:
|
||||||
|
request.app.state.settings_overrides["default_audio_format"] = fmt
|
||||||
|
updated.append("default_audio_format")
|
||||||
|
logger.info("Admin updated default_audio_format to: %s", fmt)
|
||||||
|
|
||||||
return {"updated": updated, "status": "ok"}
|
return {"updated": updated, "status": "ok"}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import WireframeBackground from './WireframeBackground.vue'
|
||||||
|
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk')
|
||||||
|
|
||||||
type MobileTab = 'submit' | 'queue'
|
type MobileTab = 'submit' | 'queue'
|
||||||
const activeTab = ref<MobileTab>('submit')
|
const activeTab = ref<MobileTab>('submit')
|
||||||
|
|
@ -7,6 +12,7 @@ const activeTab = ref<MobileTab>('submit')
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-layout">
|
<div class="app-layout">
|
||||||
|
<WireframeBackground v-if="showWireframe" />
|
||||||
<!-- Desktop: single scrollable view -->
|
<!-- Desktop: single scrollable view -->
|
||||||
<main class="layout-main">
|
<main class="layout-main">
|
||||||
<!-- URL input section -->
|
<!-- URL input section -->
|
||||||
|
|
@ -58,6 +64,8 @@ const activeTab = ref<MobileTab>('submit')
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-xl);
|
gap: var(--space-xl);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-submit,
|
.section-submit,
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,42 @@ const configStore = useConfigStore()
|
||||||
const url = ref('')
|
const url = ref('')
|
||||||
const formats = ref<FormatInfo[]>([])
|
const formats = ref<FormatInfo[]>([])
|
||||||
const selectedFormatId = ref<string | null>(null)
|
const selectedFormatId = ref<string | null>(null)
|
||||||
const isExtracting = ref(false)
|
|
||||||
const extractError = ref<string | null>(null)
|
const extractError = ref<string | null>(null)
|
||||||
const showOptions = ref(false)
|
const showOptions = ref(false)
|
||||||
|
|
||||||
// URL preview state
|
// URL preview state
|
||||||
const urlInfo = ref<UrlInfo | null>(null)
|
const urlInfo = ref<UrlInfo | null>(null)
|
||||||
const isLoadingInfo = ref(false)
|
|
||||||
const audioLocked = ref(false) // true when source is audio-only
|
const audioLocked = ref(false) // true when source is audio-only
|
||||||
|
|
||||||
|
// Unified loading state for URL check + format extraction
|
||||||
|
const isAnalyzing = ref(false)
|
||||||
|
const analyzePhase = ref<string>('')
|
||||||
|
const phaseMessages = [
|
||||||
|
'Peeking at the URL…',
|
||||||
|
'Interrogating the server…',
|
||||||
|
'Decoding the matrix…',
|
||||||
|
'Sniffing out formats…',
|
||||||
|
'Almost there…',
|
||||||
|
]
|
||||||
|
let phaseTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function startAnalyzePhase(): void {
|
||||||
|
let idx = 0
|
||||||
|
analyzePhase.value = phaseMessages[0]
|
||||||
|
phaseTimer = setInterval(() => {
|
||||||
|
idx = Math.min(idx + 1, phaseMessages.length - 1)
|
||||||
|
analyzePhase.value = phaseMessages[idx]
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAnalyzePhase(): void {
|
||||||
|
if (phaseTimer) {
|
||||||
|
clearInterval(phaseTimer)
|
||||||
|
phaseTimer = null
|
||||||
|
}
|
||||||
|
analyzePhase.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
type MediaType = 'video' | 'audio'
|
type MediaType = 'video' | 'audio'
|
||||||
const mediaType = ref<MediaType>(
|
const mediaType = ref<MediaType>(
|
||||||
(localStorage.getItem('mediarip:mediaType') as MediaType) || 'video'
|
(localStorage.getItem('mediarip:mediaType') as MediaType) || 'video'
|
||||||
|
|
@ -92,7 +119,6 @@ async function extractFormats(): Promise<void> {
|
||||||
const trimmed = url.value.trim()
|
const trimmed = url.value.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
|
|
||||||
isExtracting.value = true
|
|
||||||
extractError.value = null
|
extractError.value = null
|
||||||
formats.value = []
|
formats.value = []
|
||||||
selectedFormatId.value = null
|
selectedFormatId.value = null
|
||||||
|
|
@ -101,8 +127,6 @@ async function extractFormats(): Promise<void> {
|
||||||
formats.value = await api.getFormats(trimmed)
|
formats.value = await api.getFormats(trimmed)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
extractError.value = err.message || 'Failed to extract formats'
|
extractError.value = err.message || 'Failed to extract formats'
|
||||||
} finally {
|
|
||||||
isExtracting.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,11 +176,17 @@ function onFormatSelect(formatId: string | null): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePaste(): void {
|
function handlePaste(): void {
|
||||||
// Auto-extract on paste (populate formats + URL info silently in background)
|
// Auto-extract on paste — unified loading state
|
||||||
setTimeout(() => {
|
setTimeout(async () => {
|
||||||
if (url.value.trim()) {
|
if (url.value.trim()) {
|
||||||
extractFormats()
|
isAnalyzing.value = true
|
||||||
fetchUrlInfo()
|
startAnalyzePhase()
|
||||||
|
try {
|
||||||
|
await Promise.all([extractFormats(), fetchUrlInfo()])
|
||||||
|
} finally {
|
||||||
|
isAnalyzing.value = false
|
||||||
|
stopAnalyzePhase()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +194,6 @@ function handlePaste(): void {
|
||||||
async function fetchUrlInfo(): Promise<void> {
|
async function fetchUrlInfo(): Promise<void> {
|
||||||
const trimmed = url.value.trim()
|
const trimmed = url.value.trim()
|
||||||
if (!trimmed) return
|
if (!trimmed) return
|
||||||
isLoadingInfo.value = true
|
|
||||||
urlInfo.value = null
|
urlInfo.value = null
|
||||||
audioLocked.value = false
|
audioLocked.value = false
|
||||||
selectedEntries.value = new Set()
|
selectedEntries.value = new Set()
|
||||||
|
|
@ -183,8 +212,6 @@ async function fetchUrlInfo(): Promise<void> {
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-critical — preview is optional
|
// Non-critical — preview is optional
|
||||||
} finally {
|
|
||||||
isLoadingInfo.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -222,7 +249,7 @@ function formatDuration(seconds: number | null): string {
|
||||||
function toggleOptions(): void {
|
function toggleOptions(): void {
|
||||||
showOptions.value = !showOptions.value
|
showOptions.value = !showOptions.value
|
||||||
// Extract formats when opening options if we haven't yet
|
// Extract formats when opening options if we haven't yet
|
||||||
if (showOptions.value && !formatsReady.value && url.value.trim() && !isExtracting.value) {
|
if (showOptions.value && !formatsReady.value && url.value.trim() && !isAnalyzing.value) {
|
||||||
extractFormats()
|
extractFormats()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -258,7 +285,7 @@ function formatTooltip(fmt: string): string {
|
||||||
class="url-field"
|
class="url-field"
|
||||||
@paste="handlePaste"
|
@paste="handlePaste"
|
||||||
@keydown.enter="submitDownload"
|
@keydown.enter="submitDownload"
|
||||||
:disabled="isExtracting || store.isSubmitting"
|
:disabled="isAnalyzing || store.isSubmitting"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Action row: gear, media toggle, download button -->
|
<!-- Action row: gear, media toggle, download button -->
|
||||||
|
|
@ -304,9 +331,10 @@ function formatTooltip(fmt: string): string {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isLoadingInfo" class="url-preview loading">
|
<!-- Unified analyzing state -->
|
||||||
|
<div v-if="isAnalyzing" class="url-preview loading">
|
||||||
<span class="spinner"></span>
|
<span class="spinner"></span>
|
||||||
Checking URL…
|
{{ analyzePhase }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL preview: show what will be downloaded -->
|
<!-- URL preview: show what will be downloaded -->
|
||||||
|
|
@ -350,11 +378,6 @@ function formatTooltip(fmt: string): string {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isExtracting" class="extract-loading">
|
|
||||||
<span class="spinner"></span>
|
|
||||||
Extracting available formats…
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="extractError" class="extract-error">
|
<div v-if="extractError" class="extract-error">
|
||||||
{{ extractError }}
|
{{ extractError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -390,7 +413,7 @@ function formatTooltip(fmt: string): string {
|
||||||
:media-type="mediaType"
|
:media-type="mediaType"
|
||||||
@select="onFormatSelect"
|
@select="onFormatSelect"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="!isExtracting" class="options-hint">
|
<div v-else-if="!isAnalyzing" class="options-hint">
|
||||||
Paste a URL and formats will load automatically.
|
Paste a URL and formats will load automatically.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -505,15 +528,6 @@ button:disabled {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Loading / errors */
|
/* Loading / errors */
|
||||||
.extract-loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-sm);
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
padding: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 16px;
|
width: 16px;
|
||||||
|
|
|
||||||
181
frontend/src/components/WireframeBackground.vue
Normal file
181
frontend/src/components/WireframeBackground.vue
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* WireframeBackground — animated constellation/wireframe canvas.
|
||||||
|
*
|
||||||
|
* Floating nodes drift slowly, connected by fading lines when
|
||||||
|
* within proximity. Purely decorative, pointer-events: none.
|
||||||
|
*
|
||||||
|
* Performance: ~40 nodes, 60fps via requestAnimationFrame,
|
||||||
|
* resolution-aware (respects devicePixelRatio), pauses when
|
||||||
|
* tab is hidden. Typical GPU: <1% usage.
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
|
const canvas = ref<HTMLCanvasElement | null>(null)
|
||||||
|
const themeStore = useThemeStore()
|
||||||
|
|
||||||
|
interface Node {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
radius: number
|
||||||
|
pulseOffset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const NODE_COUNT = 45
|
||||||
|
const CONNECTION_DISTANCE = 180
|
||||||
|
const NODE_SPEED = 0.3
|
||||||
|
const LINE_OPACITY = 0.12
|
||||||
|
const NODE_OPACITY = 0.25
|
||||||
|
|
||||||
|
let nodes: Node[] = []
|
||||||
|
let animFrame = 0
|
||||||
|
let ctx: CanvasRenderingContext2D | null = null
|
||||||
|
let w = 0
|
||||||
|
let h = 0
|
||||||
|
let dpr = 1
|
||||||
|
|
||||||
|
function initNodes(): void {
|
||||||
|
nodes = []
|
||||||
|
for (let i = 0; i < NODE_COUNT; i++) {
|
||||||
|
nodes.push({
|
||||||
|
x: Math.random() * w,
|
||||||
|
y: Math.random() * h,
|
||||||
|
vx: (Math.random() - 0.5) * NODE_SPEED,
|
||||||
|
vy: (Math.random() - 0.5) * NODE_SPEED,
|
||||||
|
radius: 1 + Math.random() * 1.5,
|
||||||
|
pulseOffset: Math.random() * Math.PI * 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resize(): void {
|
||||||
|
if (!canvas.value) return
|
||||||
|
dpr = window.devicePixelRatio || 1
|
||||||
|
w = window.innerWidth
|
||||||
|
h = window.innerHeight
|
||||||
|
canvas.value.width = w * dpr
|
||||||
|
canvas.value.height = h * dpr
|
||||||
|
canvas.value.style.width = w + 'px'
|
||||||
|
canvas.value.style.height = h + 'px'
|
||||||
|
ctx = canvas.value.getContext('2d')
|
||||||
|
if (ctx) ctx.scale(dpr, dpr)
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(time: number): void {
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
// Theme colors
|
||||||
|
const isCyberpunk = themeStore.currentTheme === 'cyberpunk'
|
||||||
|
const primaryR = isCyberpunk ? 0 : 100
|
||||||
|
const primaryG = isCyberpunk ? 168 : 100
|
||||||
|
const primaryB = isCyberpunk ? 255 : 100
|
||||||
|
const accentR = isCyberpunk ? 255 : 150
|
||||||
|
const accentG = isCyberpunk ? 107 : 150
|
||||||
|
const accentB = isCyberpunk ? 43 : 150
|
||||||
|
|
||||||
|
// Update positions
|
||||||
|
for (const node of nodes) {
|
||||||
|
node.x += node.vx
|
||||||
|
node.y += node.vy
|
||||||
|
|
||||||
|
// Wrap around edges with padding
|
||||||
|
if (node.x < -20) node.x = w + 20
|
||||||
|
if (node.x > w + 20) node.x = -20
|
||||||
|
if (node.y < -20) node.y = h + 20
|
||||||
|
if (node.y > h + 20) node.y = -20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw connections
|
||||||
|
for (let i = 0; i < nodes.length; i++) {
|
||||||
|
for (let j = i + 1; j < nodes.length; j++) {
|
||||||
|
const dx = nodes[i].x - nodes[j].x
|
||||||
|
const dy = nodes[i].y - nodes[j].y
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||||
|
|
||||||
|
if (dist < CONNECTION_DISTANCE) {
|
||||||
|
const alpha = (1 - dist / CONNECTION_DISTANCE) * LINE_OPACITY
|
||||||
|
// Alternate between primary and accent color for some lines
|
||||||
|
const useAccent = (i + j) % 7 === 0
|
||||||
|
const r = useAccent ? accentR : primaryR
|
||||||
|
const g = useAccent ? accentG : primaryG
|
||||||
|
const b = useAccent ? accentB : primaryB
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(nodes[i].x, nodes[i].y)
|
||||||
|
ctx.lineTo(nodes[j].x, nodes[j].y)
|
||||||
|
ctx.strokeStyle = `rgba(${r},${g},${b},${alpha})`
|
||||||
|
ctx.lineWidth = 0.5
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw nodes with subtle pulse
|
||||||
|
const pulse = Math.sin(time * 0.001) * 0.5 + 0.5
|
||||||
|
for (const node of nodes) {
|
||||||
|
const nodePulse = Math.sin(time * 0.002 + node.pulseOffset) * 0.3 + 0.7
|
||||||
|
const r = node.radius * nodePulse
|
||||||
|
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(node.x, node.y, r, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(${primaryR},${primaryG},${primaryB},${NODE_OPACITY * nodePulse})`
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
// Occasional glow on some nodes
|
||||||
|
if (node.pulseOffset > 5) {
|
||||||
|
const glowAlpha = pulse * 0.08
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(node.x, node.y, r * 4, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = `rgba(${primaryR},${primaryG},${primaryB},${glowAlpha})`
|
||||||
|
ctx.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
animFrame = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibility(): void {
|
||||||
|
if (document.hidden) {
|
||||||
|
cancelAnimationFrame(animFrame)
|
||||||
|
} else {
|
||||||
|
animFrame = requestAnimationFrame(draw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
resize()
|
||||||
|
initNodes()
|
||||||
|
animFrame = requestAnimationFrame(draw)
|
||||||
|
window.addEventListener('resize', resize)
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelAnimationFrame(animFrame)
|
||||||
|
window.removeEventListener('resize', resize)
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-init if theme changes (colors update automatically in draw)
|
||||||
|
watch(() => themeStore.currentTheme, () => {
|
||||||
|
// No action needed — draw() reads theme each frame
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas ref="canvas" class="wireframe-bg" aria-hidden="true" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wireframe-bg {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -79,50 +79,3 @@
|
||||||
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
--shadow-glow: 0 0 20px rgba(0, 168, 255, 0.15);
|
--shadow-glow: 0 0 20px rgba(0, 168, 255, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Animated geometric background — cyberpunk only */
|
|
||||||
:root[data-theme="cyberpunk"] body::after {
|
|
||||||
background:
|
|
||||||
/* Diagonal lines moving slowly */
|
|
||||||
repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
transparent,
|
|
||||||
transparent 60px,
|
|
||||||
rgba(0, 168, 255, 0.015) 60px,
|
|
||||||
rgba(0, 168, 255, 0.015) 61px
|
|
||||||
),
|
|
||||||
repeating-linear-gradient(
|
|
||||||
-45deg,
|
|
||||||
transparent,
|
|
||||||
transparent 80px,
|
|
||||||
rgba(255, 107, 43, 0.01) 80px,
|
|
||||||
rgba(255, 107, 43, 0.01) 81px
|
|
||||||
),
|
|
||||||
/* Base grid */
|
|
||||||
linear-gradient(rgba(0, 168, 255, 0.025) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(0, 168, 255, 0.025) 1px, transparent 1px);
|
|
||||||
background-size: 200px 200px, 240px 240px, 32px 32px, 32px 32px;
|
|
||||||
animation: grid-drift 60s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes grid-drift {
|
|
||||||
0% {
|
|
||||||
background-position: 0 0, 0 0, 0 0, 0 0;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 200px 200px, -240px 240px, 32px 32px, 32px 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtle pulsing glow dot at intersections — extra depth */
|
|
||||||
:root[data-theme="cyberpunk"] body {
|
|
||||||
background-image:
|
|
||||||
radial-gradient(circle at 50% 50%, rgba(0, 168, 255, 0.04) 0%, transparent 70%);
|
|
||||||
background-size: 100% 100%;
|
|
||||||
animation: bg-pulse 8s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bg-pulse {
|
|
||||||
0% { background-size: 100% 100%; }
|
|
||||||
100% { background-size: 120% 120%; }
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue