small fixes, small sound feedback, & opening screen
This commit is contained in:
@@ -1,11 +1,150 @@
|
||||
<script>
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { page } from '$app/stores';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// --- one-time opening ritual ---------------------------------------
|
||||
// `ritualMounted` controls whether the overlay exists in the DOM at all.
|
||||
// Starting it at `false` means SSR renders nothing (avoiding a flash of
|
||||
// the overlay on every page before we've even checked localStorage),
|
||||
// and it only ever becomes `true` in the browser, once, for first-time
|
||||
// visitors.
|
||||
let ritualMounted = $state(false);
|
||||
// `ritualVisible` drives the CSS opacity transition (fade in/out).
|
||||
// Toggling this class is what actually animates the overlay; the timers
|
||||
// below just flip it true -> false at the right moments.
|
||||
let ritualVisible = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return; // localStorage doesn't exist during SSR
|
||||
|
||||
// the flag that marks "this device has already seen the ritual"
|
||||
const hasVisited = localStorage.getItem('overheard_has_visited');
|
||||
if (hasVisited) return; // not the first visit - never mount the overlay
|
||||
|
||||
// Mark the device as visited immediately (rather than after the
|
||||
// animation finishes). If we waited until the end, a user who closes
|
||||
// the tab partway through the ritual would see it again on their
|
||||
// next visit - setting the flag up front guarantees "only once ever".
|
||||
localStorage.setItem('overheard_has_visited', 'true');
|
||||
|
||||
ritualMounted = true;
|
||||
|
||||
// Fade-in timing: wait one animation frame so the overlay first
|
||||
// paints at opacity 0 (its initial CSS state), then flip
|
||||
// `ritualVisible` to true so the opacity transition to 1 actually
|
||||
// animates instead of snapping straight to visible.
|
||||
const fadeInFrame = requestAnimationFrame(() => {
|
||||
ritualVisible = true;
|
||||
});
|
||||
|
||||
// Total sequence ~5s: 1.2s fade in + 2.6s hold + 1.2s fade out.
|
||||
const fadeInMs = 1200;
|
||||
const holdMs = 2600;
|
||||
const fadeOutMs = 1200;
|
||||
|
||||
// After the fade-in finishes and the hold completes, start fading out.
|
||||
const fadeOutTimer = setTimeout(() => {
|
||||
ritualVisible = false;
|
||||
}, fadeInMs + holdMs);
|
||||
|
||||
// Once the fade-out transition has finished, unmount the overlay
|
||||
// entirely (rather than just leaving it hidden via CSS/opacity:0).
|
||||
// A hidden-but-present full-screen element would still sit in the
|
||||
// DOM at a high z-index and could intercept clicks/taps meant for
|
||||
// the map underneath, so we remove it completely once it's invisible.
|
||||
const removeTimer = setTimeout(() => {
|
||||
ritualMounted = false;
|
||||
}, fadeInMs + holdMs + fadeOutMs);
|
||||
|
||||
// cleanup if the component is destroyed mid-sequence
|
||||
return () => {
|
||||
cancelAnimationFrame(fadeInFrame);
|
||||
clearTimeout(fadeOutTimer);
|
||||
clearTimeout(removeTimer);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Map/geolocation in {@render children()} starts loading immediately and
|
||||
in parallel - this overlay just sits on top of it visually, it doesn't
|
||||
block or delay anything underneath. -->
|
||||
{@render children()}
|
||||
|
||||
<!-- one-time opening ritual overlay, first-ever visit only -->
|
||||
{#if ritualMounted}
|
||||
<div class="ritual-overlay" class:visible={ritualVisible} aria-hidden="true">
|
||||
<p class="ritual-text">Some things are only true if you're standing here.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- floating link to the archive page, hidden while already on it -->
|
||||
{#if $page.url.pathname !== '/archive'}
|
||||
<a href="/archive" class="archive-link">📁</a>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* full-screen opening ritual overlay - soft cream background matching
|
||||
the app's pastel palette, sits above everything (including the
|
||||
.archive-link below at z-index 300) while it's visible */
|
||||
.ritual-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #f9f7f4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
/* starts invisible; .visible below fades it in/out over 1.2s.
|
||||
the same transition is reused for fade-out since both are just
|
||||
opacity changes between 0 and 1 */
|
||||
opacity: 0;
|
||||
transition: opacity 1.2s ease;
|
||||
}
|
||||
|
||||
.ritual-overlay.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ritual-text {
|
||||
/* same serif used for the loading/error text in +page.svelte, for a
|
||||
calm, contemplative feel */
|
||||
font-family: Georgia, 'Times New Roman', Times, serif;
|
||||
font-size: 1.15rem;
|
||||
color: #555; /* muted dark grey, not pure black */
|
||||
letter-spacing: 0.08em;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.ritual-text {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
}
|
||||
|
||||
.archive-link {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
||||
text-decoration: none;
|
||||
font-size: 1.1rem;
|
||||
z-index: 300;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
import MapView from '$lib/components/MapView.svelte';
|
||||
|
||||
import { getNearbyMessages } from '$lib/firebase/messages.js';
|
||||
import { messagesStore } from '$lib/stores/messagesStore.js';
|
||||
import { messagesStore, setMessages } from '$lib/stores/messagesStore.js';
|
||||
import { mapStore } from '$lib/stores/mapStore.js';
|
||||
import { initAuth } from '$lib/stores/userStore.js';
|
||||
import { page } from '$app/stores'; // current route, used to highlight the active bottom-nav tab
|
||||
|
||||
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||
import SidePanel from '$lib/components/SidePanel.svelte';
|
||||
@@ -20,6 +22,9 @@
|
||||
let isMobile = $derived(windowWidth < 768);
|
||||
|
||||
onMount(() => {
|
||||
// sign this device in anonymously so messages can be linked to a persistent UID
|
||||
initAuth();
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
error = "Your browser doesn't support geolocation :(";
|
||||
return; // do nothing
|
||||
@@ -37,7 +42,9 @@
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const messages = await getNearbyMessages(position.coords.latitude, position.coords.longitude);
|
||||
messagesStore.set(messages);
|
||||
// setMessages (instead of messagesStore.set) compares against the
|
||||
// previous list and plays a soft chime for any newly-appeared pins
|
||||
setMessages(messages);
|
||||
console.log('messages loaded:', $messagesStore);
|
||||
}
|
||||
);
|
||||
@@ -50,38 +57,119 @@
|
||||
|
||||
{#if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else if lat && lng}
|
||||
<MapView {lat} {lng} />
|
||||
{:else}
|
||||
{:else if !(lat && lng)}
|
||||
<p class="loading">Looking for you...</p>
|
||||
{/if}
|
||||
|
||||
<!-- map must fill the whole screen-->
|
||||
{#if lat && lng}
|
||||
<MapView {lat} {lng} />
|
||||
<!-- map must fill the whole screen; wrapped in .map-container so desktop
|
||||
can push it right of the side panel via the media query below.
|
||||
(this also removes a duplicate <MapView> that was rendering two map instances)
|
||||
on mobile, bottom padding reserves space so pins aren't hidden behind the bottom nav -->
|
||||
{#if lat && lng}
|
||||
<div class="map-container" style="padding-bottom: {windowWidth < 768 ? '64px' : '0'}">
|
||||
<MapView {lat} {lng} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- show the right panel based on mobile or desktop-->
|
||||
{#if windowWidth < 768}
|
||||
<BottomSheet message={$mapStore.selectedMessage} />
|
||||
{:else}
|
||||
<!-- SidePanel itself swaps between the list view and a message detail
|
||||
view depending on whether a message is selected -->
|
||||
<SidePanel message={$mapStore.selectedMessage} />
|
||||
{/if}
|
||||
|
||||
<!--floating action button for adding a message-->
|
||||
{#if !$mapStore.composing}
|
||||
<!-- pin legend, desktop only.
|
||||
icons are inline copies of the exact shapes/viewBoxes from pins.js
|
||||
(unreadPin/readPin/echoedPin/locationPin) instead of unrelated emoji,
|
||||
so the key visually matches what's on the map. message-pin icons use
|
||||
one fixed pastel fill here (rather than pins.js's randomPastel()) since
|
||||
a legend needs a single representative swatch, not a random one. -->
|
||||
{#if !isMobile}
|
||||
<div class="legend">
|
||||
<!-- each icon sits in a fixed 36x36 .legend-icon-wrap so the text
|
||||
column stays aligned, even though the svgs below are sized
|
||||
differently from each other. the star/heart shapes occupy a
|
||||
smaller fraction of their own viewBox than the two circles do
|
||||
of theirs, so they need a bigger svg size to LOOK the same size
|
||||
on screen - sizes below were picked by eye, not by matching
|
||||
pixel dimensions -->
|
||||
<div class="legend-item">
|
||||
<span class="legend-icon-wrap">
|
||||
<svg class="legend-icon" viewBox="0 0 32 32" width="23" height="23">
|
||||
<circle cx="16" cy="16" r="14" fill="#111" stroke="white" stroke-width="3"/>
|
||||
<circle cx="16" cy="16" r="5" fill="white"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>You are here</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-icon-wrap">
|
||||
<svg class="legend-icon" viewBox="0 0 60 60" width="36" height="36">
|
||||
<polygon points="30,12 34,24 47,24 37,32 41,44 30,36 19,44 23,32 13,24 26,24" fill="hsl(265, 60%, 80%)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Unread</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-icon-wrap">
|
||||
<svg class="legend-icon" viewBox="0 0 50 50" width="25" height="25">
|
||||
<circle cx="25" cy="25" r="20" fill="hsl(265, 60%, 80%)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Read</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-icon-wrap">
|
||||
<svg class="legend-icon" viewBox="0 0 56 56" width="30" height="30">
|
||||
<path d="M28,46 C28,46 10,33 10,20 C10,13 15,9 21,9 C25,9 28,12 28,17 C28,12 31,9 35,9 C41,9 46,13 46,20 C46,33 28,46 28,46 Z" fill="hsl(265, 60%, 80%)"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Echoed</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!--floating action button for adding a message; hidden while a message is open-->
|
||||
{#if !$mapStore.composing && !$mapStore.selectedMessage}
|
||||
<button
|
||||
class="fab"
|
||||
class:shifted={isMobile && $mapStore.selectedMessage}
|
||||
onclick={() => mapStore.set(
|
||||
{selectedMessage: null, composing: true })}>
|
||||
+
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- bottom nav, mobile only -->
|
||||
{#if windowWidth < 768}
|
||||
<nav class="bottom-nav">
|
||||
<a href="/" class="nav-item" class:active={$page.url.pathname === '/'}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||
</svg>
|
||||
<span>Map</span>
|
||||
</a>
|
||||
<a href="/archive" class="nav-item" class:active={$page.url.pathname === '/archive'}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"/>
|
||||
<path d="M2 9h20"/>
|
||||
<path d="M9 4v5"/>
|
||||
<path d="M15 4v5"/>
|
||||
</svg>
|
||||
<span>Archive</span>
|
||||
</a>
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<!--compose sheet (making a message)-->
|
||||
{#if $mapStore.composing}
|
||||
<ComposeSheet {lat} {lng} />
|
||||
<!-- pass isMobile so ComposeSheet can render as a bottom sheet on mobile
|
||||
(unchanged) vs. a centered popup with backdrop on desktop -->
|
||||
<ComposeSheet {lat} {lng} {isMobile} />
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -102,30 +190,117 @@
|
||||
|
||||
}
|
||||
|
||||
/* purple FAB; only rendered when nothing is composing/selected, so no
|
||||
"shifted" state is needed anymore (that rule has been removed) */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
bottom: 5rem;
|
||||
right: 1.5rem;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #111;
|
||||
background: #c4a8f5;
|
||||
color: white;
|
||||
font-size: 1.8rem;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
||||
/* flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
||||
z-index: 150;
|
||||
display: flex;
|
||||
align-items:center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
transition: bottom 0.35s cubic-bezier(0.32, 0.72, 0, 1); /* match the sheet's slide timing */
|
||||
}
|
||||
|
||||
/* lift it above the bottom sheet so it doesn't get covered, x position stays the same */
|
||||
.fab.shifted {
|
||||
bottom: 35vh;
|
||||
/* mobile-only bottom nav bar with Map/Archive links */
|
||||
.bottom-nav {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
background: white;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
text-decoration: none;
|
||||
color: #ccc;
|
||||
font-size: 0.7rem;
|
||||
font-family: sans-serif;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
/* highlight the tab matching the current route */
|
||||
.nav-item.active {
|
||||
color: #c4a8f5;
|
||||
}
|
||||
|
||||
/* map fills the screen on mobile; on desktop it's pushed right of the
|
||||
fixed-width SidePanel (340px) so the panel doesn't sit on top of it.
|
||||
box-sizing: border-box so the inline padding-bottom (reserved for the
|
||||
bottom nav on mobile) shrinks the map instead of overflowing 100vh */
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.map-container {
|
||||
margin-left: 340px;
|
||||
width: calc(100% - 340px);
|
||||
}
|
||||
}
|
||||
|
||||
/* floating legend explaining the map pin shapes/colors, desktop only.
|
||||
anchored bottom-left, just to the right of the 340px side panel.
|
||||
flat 2D style for now - shadow removed, paper-style shadows may be added later */
|
||||
.legend {
|
||||
position: fixed;
|
||||
bottom: 1.5rem;
|
||||
left: calc(340px + 4rem);
|
||||
background: #f3f0fa;
|
||||
border-radius: 14px;
|
||||
padding: 0.9rem 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
z-index: 200;
|
||||
font-size: 0.85rem;
|
||||
color: #444;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
/* fixed 36x36 box for every icon, centered, so the text after each icon
|
||||
lines up in a consistent column regardless of each icon's own width/height
|
||||
(which now vary so the shapes LOOK the same size - see template) */
|
||||
.legend-icon-wrap {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* flat 2D style for now - drop-shadow filter removed, paper-style shadows may be added later */
|
||||
.legend-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
</style>
|
||||
169
src/routes/archive/+page.svelte
Normal file
169
src/routes/archive/+page.svelte
Normal file
@@ -0,0 +1,169 @@
|
||||
<script>
|
||||
// Archive page: lists every message this device (anonymous Firebase UID) has authored
|
||||
import { onMount } from 'svelte';
|
||||
import { getMyMessages } from '$lib/firebase/messages.js';
|
||||
import { getDecayInfo } from '$lib/utils/time.js';
|
||||
|
||||
let messages = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
messages = await getMyMessages();
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="archive">
|
||||
<div class="archive-header">
|
||||
<a href="/" class="back">← Back to map</a>
|
||||
<h1>Your messages</h1>
|
||||
<p class="subtitle">Everything you've left behind</p>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="loading">Loading your messages...</div>
|
||||
|
||||
{:else if messages.length === 0}
|
||||
<div class="empty">
|
||||
<p>You haven't left any messages yet.</p>
|
||||
<a href="/" class="go-back">Go leave one →</a>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="list">
|
||||
{#each messages as msg}
|
||||
<!-- fade-out timing for this message -->
|
||||
{@const decay = getDecayInfo(msg.createdAt, msg.lastEchoAt)}
|
||||
<div class="card" class:faded={decay.isExpired}>
|
||||
|
||||
{#if msg.imageUrl}
|
||||
<img class="card-img" src={msg.imageUrl} alt="message" />
|
||||
{/if}
|
||||
|
||||
<p class="card-text">{msg.text}</p>
|
||||
|
||||
<div class="card-meta">
|
||||
{#if decay.isExpired}
|
||||
<span class="status faded">🌫️ Faded</span>
|
||||
{:else}
|
||||
<span class="status alive">✦ Alive</span>
|
||||
{/if}
|
||||
<span>•</span>
|
||||
<span>left {decay.daysAgo} days ago</span>
|
||||
<span>•</span>
|
||||
<span>{decay.daysLeft} days left</span>
|
||||
<span>•</span>
|
||||
<span>echoed {msg.echoCount} times</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.archive {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
.archive-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.back {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.back:hover { color: #111; }
|
||||
|
||||
h1 {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: #aaa;
|
||||
padding: 3rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 3rem 0;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.go-back {
|
||||
display: inline-block;
|
||||
margin-top: 0.75rem;
|
||||
color: #c4a8f5;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 14px;
|
||||
padding: 1.2rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.card.faded {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.card-img {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
object-fit: cover;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card-text {
|
||||
font-size: 0.95rem;
|
||||
color: #111;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: #bbb;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.status.alive { color: #c4a8f5; }
|
||||
.status.faded { color: #ccc; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user