Public landing page live map; docs for Discord, live embeds, email
This commit is contained in:
11 files changed
+195
-10
No files matched your search
@@ -100,6 +100,7 @@ docker-compose.yml Panel deployment
|
|||||||
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
|
- [Admin guide](docs/admin-guide.md) — deploying, HTTPS, modpacks, players, backups
|
||||||
- [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection
|
- [Official server setup](docs/server-integration.md) — private authentication, supported versions, activity and UUID protection
|
||||||
- [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players
|
- [The SCOPENET Map](docs/map.md) — the built-in world map: terrain, guild land, shops and players
|
||||||
|
- [Discord, live embeds and email](docs/discord-and-email.md) — customisable announcements, self-updating status boards and emails to players
|
||||||
- [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change
|
- [Rewards, limits and permission nodes](docs/rewards-and-limits.md) — what quests can grant, and every setting you can change
|
||||||
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
|
- [Architecture](docs/architecture.md) — how the pieces fit, API reference
|
||||||
- [Development](docs/development.md) — running everything locally, tests
|
- [Development](docs/development.md) — running everything locally, tests
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Discord messages, live embeds and emails
|
||||||
|
|
||||||
|
## Announcements (Admin panel → Discord → Announcements)
|
||||||
|
|
||||||
|
Three messages are posted by the panel's webhook (Settings → Discord → webhook): **Achievement unlocked**, **New player** and **New guild**. Each one is fully editable, with a live preview built the way Discord will show it:
|
||||||
|
|
||||||
|
- Message text above the box (mentions such as `<@&roleid>` work there), title and link, description, colour, a small picture on the right, a big picture at the bottom, author and footer (with icons), the time, and any number of side-by-side or full-width fields.
|
||||||
|
- The sender's name and picture.
|
||||||
|
- **Placeholders** such as `{player}`, `{avatar}`, `{achievement}`, `{achievement_description}`, `{xp}`, `{level}`, `{rank_title}`, `{guild_line}` (achievements), `{members_total}` (new players), `{guild}`, `{leader}`, `{members}`, `{claims}` (guilds) and `{server_name}`, `{time}` everywhere. Click a placeholder to insert it where you are typing. Unknown ones stay as typed so mistakes are easy to spot.
|
||||||
|
- Player names are cleaned so they can't ping anyone or break the formatting.
|
||||||
|
|
||||||
|
Switch each message on or off, and press **Save & send a sample** to see it in your channel.
|
||||||
|
|
||||||
|
## Live embeds (Discord → Live embeds)
|
||||||
|
|
||||||
|
Four boards that the panel posts once and then **edits in place**, so the channel never fills up:
|
||||||
|
|
||||||
|
| Embed | Shows | Rows are set by |
|
||||||
|
|---|---|---|
|
||||||
|
| **Server status** | Every server: online, players, TPS, version | the line template |
|
||||||
|
| **Active guilds** | Top guilds with level, members, land and leader | rows shown |
|
||||||
|
| **Player leaderboard** | Top players by playtime, level, kills or blocks | ranked by, rows shown |
|
||||||
|
| **Richest players** | Top balances of one server economy | economy of, rows shown |
|
||||||
|
|
||||||
|
Every embed has the same editor as the announcements, plus a **line for each row** (for example `{rank}. **{player}** — {value}`) that fills `{rows}` in the description, an update interval (1 minute to 1 hour), and an optional **own webhook** so each board can live in its own channel. If someone deletes the message, the next update posts a fresh one; **Post as new message** forces it.
|
||||||
|
|
||||||
|
## Emails (Admin panel → Emails)
|
||||||
|
|
||||||
|
Set up the sender and Resend key under **Settings → Connections** first. Then:
|
||||||
|
|
||||||
|
1. Write a **template**: subject and message with placeholders (`{player}`, `{level}`, `{rank_title}`, `{guild}`, `{brand}`, `{panel_url}`…). Simple formatting turns into a branded HTML email in your launcher colours: `# Heading`, `**bold**`, `- list`, `[link](https://…)`, `[button: Label](https://…)`.
|
||||||
|
2. Check the live **preview**, and **send a test** to yourself.
|
||||||
|
3. **Send** to everyone with an email, a group, or chosen players. The panel shows how many will receive it first, sends them one by one in the background, and keeps a log with failures and their reasons.
|
||||||
|
|
||||||
|
Every email has an unsubscribe link; players who use it are skipped by later emails. Mark a message as an **account notice** to reach everyone regardless, without the unsubscribe link: use that only for things about their account.
|
||||||
|
|
||||||
|
## Live map on the landing page
|
||||||
|
|
||||||
|
In **Landing page**, add a **Live map** section. Choose the server (or leave it at the first server with a map), the height, and what visitors may see: guild land and places are on by default, **live player positions are off** (they let strangers find players). Visitors can pan and zoom the same map your players see in the launcher; removing the section closes the public map again.
|
||||||
@@ -27,6 +27,10 @@ Players with a linked server get a **Live Map** button on the Home screen. Pan b
|
|||||||
|
|
||||||
**Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update).
|
**Servers → your server** shows a map card with the number of tiles, how much space they use, when the server last sent something and the live map itself. **Redraw world** deletes the stored tiles; the server notices and draws everything again (useful after a world reset or a renderer update).
|
||||||
|
|
||||||
|
## On your public landing page
|
||||||
|
|
||||||
|
Add a **Live map** section in the landing page builder to show the map to visitors, with only the layers you allow. See [discord-and-email.md](discord-and-email.md#live-map-on-the-landing-page).
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
- The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp.
|
- The game server reads its region files (`region/*.mca`), works out the top block of every column, and writes 256 × 256 pixel tiles at one pixel per block. Zoomed-out levels are built from them, keeping real map colours so coastlines and roads stay sharp.
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ pub fn api(state: &AppState) -> Router<AppState> {
|
|||||||
.route("/guilds/{id}/invites", post(map::send_invite))
|
.route("/guilds/{id}/invites", post(map::send_invite))
|
||||||
.route("/leaderboard", get(servers::global_leaderboard))
|
.route("/leaderboard", get(servers::global_leaderboard))
|
||||||
.route("/landing", get(landing::public_landing))
|
.route("/landing", get(landing::public_landing))
|
||||||
|
.route("/public/map/{id}", get(worldmap::public_info))
|
||||||
|
.route("/public/map/{id}/overlay", get(worldmap::public_overlay))
|
||||||
.route("/email/unsubscribe", get(emails::unsubscribe))
|
.route("/email/unsubscribe", get(emails::unsubscribe))
|
||||||
.route("/launcher/authlib-injector.json", get(account::authlib_index))
|
.route("/launcher/authlib-injector.json", get(account::authlib_index))
|
||||||
.route("/launcher/authlib-injector.jar", get(account::authlib_jar))
|
.route("/launcher/authlib-injector.jar", get(account::authlib_jar))
|
||||||
|
|||||||
@@ -171,3 +171,49 @@ pub async fn tile(State(state): State<AppState>, Path((id, dim, z, x, file)): Pa
|
|||||||
}
|
}
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public map (landing page)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The landing page's map section decides what the public may see. Without an enabled map section, nothing is public.
|
||||||
|
async fn landing_map(state: &AppState, requested: i64) -> AppResult<(i64, Value)> {
|
||||||
|
let cfg = crate::routes::landing::get_config(state).await?;
|
||||||
|
let block = cfg.blocks.iter().find(|b| b.block_type == "map" && b.enabled).ok_or_else(|| AppError::not_found("no public map"))?;
|
||||||
|
let wanted = block.options.get("server_id").and_then(|v| v.as_i64().or_else(|| v.as_str().and_then(|s| s.parse().ok()))).unwrap_or(0);
|
||||||
|
let id: i64 = if wanted > 0 {
|
||||||
|
wanted
|
||||||
|
} else {
|
||||||
|
sqlx::query_scalar("SELECT COALESCE(MIN(id), 0) FROM game_servers WHERE live_map_enabled = 1").fetch_one(&state.db).await?
|
||||||
|
};
|
||||||
|
if id == 0 || (requested != 0 && requested != id) {
|
||||||
|
return Err(AppError::not_found("no public map"));
|
||||||
|
}
|
||||||
|
let server = get_server(state, id).await?;
|
||||||
|
if !server.map_enabled {
|
||||||
|
return Err(AppError::not_found("no public map"));
|
||||||
|
}
|
||||||
|
let flag = |key: &str, default: bool| block.options.get(key).and_then(|v| v.as_bool().or_else(|| v.as_str().map(|s| s == "true"))).unwrap_or(default);
|
||||||
|
Ok((id, json!({ "claims": flag("show_claims", true), "pins": flag("show_pins", true), "players": flag("show_players", false) })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/public/map/{server}`: tile key and dimensions for the landing page map (0 = the server the page chose).
|
||||||
|
pub async fn public_info(State(state): State<AppState>, Path(requested): Path<i64>) -> AppResult<Json<Value>> {
|
||||||
|
let (id, layers) = landing_map(&state, requested).await?;
|
||||||
|
let mut v = info(&state, id, true).await;
|
||||||
|
v["server_id"] = json!(id);
|
||||||
|
v["layers"] = layers;
|
||||||
|
Ok(Json(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn public_overlay(State(state): State<AppState>, Path(requested): Path<i64>) -> AppResult<Json<Value>> {
|
||||||
|
let (id, layers) = landing_map(&state, requested).await?;
|
||||||
|
let overlay = state.worldmap.overlay(id);
|
||||||
|
let on = |k: &str| layers[k].as_bool().unwrap_or(false);
|
||||||
|
Ok(Json(json!({
|
||||||
|
"players": if on("players") { json!(state.worldmap.live_players(id)) } else { json!([]) },
|
||||||
|
"claims": if on("claims") { overlay.get("claims").cloned().unwrap_or(json!([])) } else { json!([]) },
|
||||||
|
"pins": if on("pins") { overlay.get("pins").cloned().unwrap_or(json!([])) } else { json!([]) },
|
||||||
|
"updated": state.worldmap.stats(id).last_overlay_at,
|
||||||
|
})))
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
mod common;
|
||||||
|
use common::*;
|
||||||
|
|
||||||
|
fn tile(zoom: u8, x: i32, y: i32) -> Vec<u8> {
|
||||||
|
let png = [0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4];
|
||||||
|
let mut v = vec![zoom];
|
||||||
|
v.extend(x.to_be_bytes());
|
||||||
|
v.extend(y.to_be_bytes());
|
||||||
|
v.extend((png.len() as u32).to_be_bytes());
|
||||||
|
v.extend(png);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_landing_page_decides_what_the_public_sees_of_the_map() {
|
||||||
|
let t = setup().await;
|
||||||
|
let admin = t.login("admin", "supersecret").await;
|
||||||
|
let (_, srv) = t.call("POST", "/api/admin/servers", Some(&admin), Some(json!({"name": "SMP", "instance_id": "smp"}))).await;
|
||||||
|
let key = srv["token"].as_str().unwrap().to_string();
|
||||||
|
assert_eq!(t.call_raw("POST", "/api/server/v1/map/tiles?dim=minecraft:overworld", Some(&key), tile(0, 0, 0)).await.0, StatusCode::OK);
|
||||||
|
t.call("POST", "/api/server/v1/map/players", Some(&key), Some(json!({"players": [{"uuid": "11111111-1111-1111-1111-111111111111", "name": "Steve", "dimension": "minecraft:overworld", "x": 1.0, "y": 64.0, "z": 2.0, "yaw": 0.0}]}))).await;
|
||||||
|
t.call("POST", "/api/server/v1/map/overlay", Some(&key), Some(json!({"claims": [{"id": "c"}], "pins": [{"id": "p"}]}))).await;
|
||||||
|
|
||||||
|
// No map section on the landing page: nothing is public.
|
||||||
|
assert_eq!(t.call("GET", "/api/v1/public/map/0", None, None).await.0, StatusCode::NOT_FOUND);
|
||||||
|
|
||||||
|
let landing = |opts: Value| json!({"blocks": [{"id": "m1", "type": "map", "enabled": true, "options": opts}]});
|
||||||
|
assert_eq!(t.call("PUT", "/api/admin/landing", Some(&admin), Some(landing(json!({"show_players": false})))).await.0, StatusCode::OK);
|
||||||
|
let (s, info) = t.call("GET", "/api/v1/public/map/0", None, None).await;
|
||||||
|
assert_eq!(s, StatusCode::OK, "{info}");
|
||||||
|
assert_eq!(info["ready"], true);
|
||||||
|
assert_eq!(info["layers"]["players"], false);
|
||||||
|
assert_eq!(info["layers"]["claims"], true);
|
||||||
|
// The tile key from the public info loads tiles without signing in.
|
||||||
|
let token = info["token"].as_str().unwrap();
|
||||||
|
let (s, png) = t.fetch(&format!("/api/map/{}/overworld/0/0/0.png?t={token}", info["server_id"])).await;
|
||||||
|
assert_eq!((s, png.len()), (StatusCode::OK, 12));
|
||||||
|
|
||||||
|
// Players stay hidden unless the admin turns them on.
|
||||||
|
let (_, o) = t.call("GET", "/api/v1/public/map/0/overlay", None, None).await;
|
||||||
|
assert_eq!(o["players"].as_array().unwrap().len(), 0);
|
||||||
|
assert_eq!(o["claims"].as_array().unwrap().len(), 1);
|
||||||
|
t.call("PUT", "/api/admin/landing", Some(&admin), Some(landing(json!({"show_players": true, "show_pins": false})))).await;
|
||||||
|
let (_, o) = t.call("GET", "/api/v1/public/map/0/overlay", None, None).await;
|
||||||
|
assert_eq!(o["players"][0]["name"], "Steve");
|
||||||
|
assert_eq!(o["pins"].as_array().unwrap().len(), 0);
|
||||||
|
|
||||||
|
// Another server's map isn't exposed, and turning the section off closes it.
|
||||||
|
assert_eq!(t.call("GET", "/api/v1/public/map/999", None, None).await.0, StatusCode::NOT_FOUND);
|
||||||
|
t.call("PUT", "/api/admin/landing", Some(&admin), Some(json!({"blocks": [{"id": "m1", "type": "map", "enabled": false, "options": {}}]}))).await;
|
||||||
|
assert_eq!(t.call("GET", "/api/v1/public/map/0", None, None).await.0, StatusCode::NOT_FOUND);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import MapViewer from '@scopenet/map/MapViewer.svelte';
|
||||||
|
import { startFeed, type MapInfo, type MapLayers, type MapOverlay } from '@scopenet/map';
|
||||||
|
import { get } from '../lib/api';
|
||||||
|
|
||||||
|
// The public live map on the landing page. The admin chooses the server and which layers visitors may see.
|
||||||
|
let { serverId = 0, height = 560 }: { serverId?: number; height?: number } = $props();
|
||||||
|
|
||||||
|
let info = $state<(MapInfo & { layers?: MapLayers }) | null>(null);
|
||||||
|
let overlay = $state<MapOverlay | null>(null);
|
||||||
|
let error = $state('');
|
||||||
|
const tileUrl = (slug: string, z: number, x: number, y: number) => `${info?.tile_base}/${slug}/${z}/${x}/${y}.png?t=${info?.token}`;
|
||||||
|
const available = $derived(info?.layers ?? { claims: true, pins: true, players: false });
|
||||||
|
|
||||||
|
onMount(() => startFeed({
|
||||||
|
loadInfo: () => get<MapInfo & { layers: MapLayers }>(`/api/v1/public/map/${serverId}`),
|
||||||
|
loadOverlay: () => get<MapOverlay>(`/api/v1/public/map/${serverId}/overlay`),
|
||||||
|
onInfo: (i) => { info = i; error = ''; },
|
||||||
|
onOverlay: (o) => { overlay = o; error = ''; },
|
||||||
|
onError: (m) => { error = m; },
|
||||||
|
overlayMs: 4000
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="frame" style:height="{Math.min(1000, Math.max(300, height))}px">
|
||||||
|
<MapViewer {info} {overlay} {tileUrl} {error} {available} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.frame { width: 100%; border-radius: 20px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.1); box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.7); --map-accent: var(--accent, #8b6cff); }
|
||||||
|
</style>
|
||||||
@@ -44,6 +44,7 @@ export const BLOCK_SCHEMAS: Record<BlockType, BlockSchema> = {
|
|||||||
divider: { type: 'divider', label: 'Divider', description: 'Space or rule between sections', group: 'Layout', defaults: { style: 'line', height: 48 }, fields: [choice('style', 'Style', ['line', 'space', 'glow']), field('height', 'Height', 'number')] },
|
divider: { type: 'divider', label: 'Divider', description: 'Space or rule between sections', group: 'Layout', defaults: { style: 'line', height: 48 }, fields: [choice('style', 'Style', ['line', 'space', 'glow']), field('height', 'Height', 'number')] },
|
||||||
testimonials: { type: 'testimonials', label: 'Testimonials', description: 'Player quotes and reviews', group: 'Content', defaults: { columns: '3', items: [] }, fields: [choice('columns', 'Columns', ['2', '3']), list('items', 'Quotes', [field('quote', 'Quote', 'textarea'), field('name', 'Player name'), field('role', 'Role')], { quote: '', name: '', role: '' })] },
|
testimonials: { type: 'testimonials', label: 'Testimonials', description: 'Player quotes and reviews', group: 'Content', defaults: { columns: '3', items: [] }, fields: [choice('columns', 'Columns', ['2', '3']), list('items', 'Quotes', [field('quote', 'Quote', 'textarea'), field('name', 'Player name'), field('role', 'Role')], { quote: '', name: '', role: '' })] },
|
||||||
video: { type: 'video', label: 'Video', description: 'Hosted video or YouTube embed', group: 'Media', defaults: { url: '', caption: '', poster: '' }, fields: [field('url', 'Video or YouTube URL', 'media'), field('poster', 'Poster image', 'media'), field('caption', 'Caption')] },
|
video: { type: 'video', label: 'Video', description: 'Hosted video or YouTube embed', group: 'Media', defaults: { url: '', caption: '', poster: '' }, fields: [field('url', 'Video or YouTube URL', 'media'), field('poster', 'Poster image', 'media'), field('caption', 'Caption')] },
|
||||||
|
map: { type: 'map', label: 'Live map', description: 'The SCOPENET Map of a server: terrain, guild land and shops', group: 'Network', defaults: { server_id: 0, height: 560, show_claims: true, show_pins: true, show_players: false }, fields: [field('server_id', 'Server id (0 = the first server with a map)', 'number'), field('height', 'Height (px)', 'number'), field('show_claims', 'Show guild land', 'toggle'), field('show_pins', 'Show spawn, warps, markets and shops', 'toggle'), field('show_players', 'Show players online (live positions!)', 'toggle')] },
|
||||||
buttons: { type: 'buttons', label: 'Button group', description: 'Custom links and action buttons', group: 'Conversion', defaults: { align: 'center', items: [] }, fields: [choice('align', 'Alignment', ['left', 'center']), list('items', 'Buttons', [field('label', 'Label'), field('url', 'URL', 'url'), choice('style', 'Style', ['primary', 'secondary'])], { label: 'Visit', url: '', style: 'primary' })] },
|
buttons: { type: 'buttons', label: 'Button group', description: 'Custom links and action buttons', group: 'Conversion', defaults: { align: 'center', items: [] }, fields: [choice('align', 'Alignment', ['left', 'center']), list('items', 'Buttons', [field('label', 'Label'), field('url', 'URL', 'url'), choice('style', 'Style', ['primary', 'secondary'])], { label: 'Visit', url: '', style: 'primary' })] },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export interface FaqItem {
|
|||||||
answer: string;
|
answer: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials' | 'text' | 'image' | 'split' | 'features' | 'gallery' | 'cta' | 'divider' | 'testimonials' | 'video' | 'buttons';
|
export type BlockType = 'hero' | 'download' | 'servers' | 'leaderboard' | 'stats' | 'instances' | 'news' | 'faq' | 'socials' | 'text' | 'image' | 'split' | 'features' | 'gallery' | 'cta' | 'divider' | 'testimonials' | 'video' | 'buttons' | 'map';
|
||||||
|
|
||||||
export interface LandingBlock {
|
export interface LandingBlock {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
} from '@lucide/svelte';
|
} from '@lucide/svelte';
|
||||||
import { get, formatBytes } from '../lib/api';
|
import { get, formatBytes } from '../lib/api';
|
||||||
import { session } from '../lib/session.svelte';
|
import { session } from '../lib/session.svelte';
|
||||||
|
import LandingMap from '../components/LandingMap.svelte';
|
||||||
import type { LandingConfig, HostedDownload, BlockType, FaqItem } from '../lib/types';
|
import type { LandingConfig, HostedDownload, BlockType, FaqItem } from '../lib/types';
|
||||||
|
|
||||||
let config = $state<LandingConfig | null>(null);
|
let config = $state<LandingConfig | null>(null);
|
||||||
@@ -724,6 +725,11 @@
|
|||||||
{#if block.options?.caption}<p class="muted">{block.options.caption}</p>{/if}
|
{#if block.options?.caption}<p class="muted">{block.options.caption}</p>{/if}
|
||||||
</div></section>
|
</div></section>
|
||||||
|
|
||||||
|
{:else if block.type === 'map'}
|
||||||
|
<section class="section" id={sectionId(block)}><div class="container"><div class="section-head"><h2>{block.title}</h2><p class="muted">{block.subtitle}</p></div>
|
||||||
|
<LandingMap serverId={Number(block.options?.server_id) || 0} height={Number(block.options?.height) || 560} />
|
||||||
|
</div></section>
|
||||||
|
|
||||||
{:else if block.type === 'buttons'}
|
{:else if block.type === 'buttons'}
|
||||||
<section class="section" id={sectionId(block)}><div class="container"><div class="section-head"><h2>{block.title}</h2><p class="muted">{block.subtitle}</p></div><div class="custom-actions" class:align-left={block.options?.align === 'left'}>
|
<section class="section" id={sectionId(block)}><div class="container"><div class="section-head"><h2>{block.title}</h2><p class="muted">{block.subtitle}</p></div><div class="custom-actions" class:align-left={block.options?.align === 'left'}>
|
||||||
{#each block.options?.items ?? [] as item}{#if item.label && item.url}<a class="custom-button" class:secondary={item.style === 'secondary'} href={safeLink(item.url)}>{item.label}</a>{/if}{/each}
|
{#each block.options?.items ?? [] as item}{#if item.label && item.url}<a class="custom-button" class:secondary={item.style === 'secondary'} href={safeLink(item.url)}>{item.label}</a>{/if}{/each}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
// click on a player does through the `player` snippet.
|
// click on a player does through the `player` snippet.
|
||||||
let {
|
let {
|
||||||
info = null, overlay = null, tileUrl, avatarUrl, selfUuid = null, error = '', initialDimension = '',
|
info = null, overlay = null, tileUrl, avatarUrl, selfUuid = null, error = '', initialDimension = '',
|
||||||
player, toolbar, onplayer
|
player, toolbar, onplayer, available = { claims: true, pins: true, players: true }
|
||||||
}: {
|
}: {
|
||||||
info: MapInfo | null;
|
info: MapInfo | null;
|
||||||
overlay: MapOverlay | null;
|
overlay: MapOverlay | null;
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
/** Extra buttons for the top bar. */
|
/** Extra buttons for the top bar. */
|
||||||
toolbar?: Snippet;
|
toolbar?: Snippet;
|
||||||
onplayer?: (p: LivePlayer | null) => void;
|
onplayer?: (p: LivePlayer | null) => void;
|
||||||
|
/** Which layers exist for this viewer (the public map may hide some). */
|
||||||
|
available?: MapLayers;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
@@ -97,23 +99,23 @@
|
|||||||
<span class="chip solo">{dims[0].label}</span>
|
<span class="chip solo">{dims[0].label}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="chips" aria-label="Layers">
|
<div class="chips" aria-label="Layers">
|
||||||
<button class="chip" class:on={layers.claims} onclick={() => (layers.claims = !layers.claims)} title="Guild land">
|
{#if available.claims}<button class="chip" class:on={layers.claims} onclick={() => (layers.claims = !layers.claims)} title="Guild land">
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M4 4h16v16H4z" /><path d="M4 12h16M12 4v16" /></svg> Claims <b>{claimsHere}</b>
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M4 4h16v16H4z" /><path d="M4 12h16M12 4v16" /></svg> Claims <b>{claimsHere}</b>
|
||||||
</button>
|
</button>{/if}
|
||||||
<button class="chip" class:on={layers.pins} onclick={() => (layers.pins = !layers.pins)} title="Spawn, warps, markets and shops">
|
{#if available.pins}<button class="chip" class:on={layers.pins} onclick={() => (layers.pins = !layers.pins)} title="Spawn, warps, markets and shops">
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 21s7-6.2 7-11.5A7 7 0 0 0 5 9.5C5 14.8 12 21 12 21z" /><circle cx="12" cy="9.5" r="2.4" /></svg> Places <b>{pinsHere}</b>
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 21s7-6.2 7-11.5A7 7 0 0 0 5 9.5C5 14.8 12 21 12 21z" /><circle cx="12" cy="9.5" r="2.4" /></svg> Places <b>{pinsHere}</b>
|
||||||
</button>
|
</button>{/if}
|
||||||
<button class="chip" class:on={layers.players} onclick={() => (layers.players = !layers.players)} title="Players online">
|
{#if available.players}<button class="chip" class:on={layers.players} onclick={() => (layers.players = !layers.players)} title="Players online">
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="3.5" /><path d="M5 20c.8-4 3.5-6 7-6s6.2 2 7 6" /></svg> Players <b>{here.length}</b>
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="3.5" /><path d="M5 20c.8-4 3.5-6 7-6s6.2 2 7 6" /></svg> Players <b>{here.length}</b>
|
||||||
</button>
|
</button>{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if toolbar}{@render toolbar()}{/if}
|
{#if toolbar}{@render toolbar()}{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<button class="chip" class:on={listOpen} onclick={() => (listOpen = !listOpen)} disabled={!everyone.length}>
|
{#if available.players}<button class="chip" class:on={listOpen} onclick={() => (listOpen = !listOpen)} disabled={!everyone.length}>
|
||||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg> Online <b>{everyone.length}</b>
|
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg> Online <b>{everyone.length}</b>
|
||||||
</button>
|
</button>{/if}
|
||||||
{#if listOpen && everyone.length}
|
{#if listOpen && everyone.length}
|
||||||
<ul class="plist" aria-label="Players online">
|
<ul class="plist" aria-label="Players online">
|
||||||
{#each everyone as p (p.uuid)}
|
{#each everyone as p (p.uuid)}
|
||||||
|
|||||||
Reference in new issue
Block a user