Compare commits
3 commits
d71e06ea39
...
2cbb64e930
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cbb64e930 | ||
|
|
96278333b7 | ||
|
|
beec58ae63 |
19 changed files with 577 additions and 387 deletions
|
|
@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::auth::is_authorized;
|
||||
use crate::db::{self, PlatformInput};
|
||||
use crate::db::{self, CollabInput, PlatformInput};
|
||||
use crate::state::AppState;
|
||||
use crate::twitch;
|
||||
|
||||
|
|
@ -30,6 +30,10 @@ fn unauthorized() -> ApiError {
|
|||
)
|
||||
}
|
||||
|
||||
fn bad_request(message: &str) -> ApiError {
|
||||
(StatusCode::BAD_REQUEST, Json(json!({ "error": message })))
|
||||
}
|
||||
|
||||
fn require_admin(state: &AppState, headers: &HeaderMap) -> Result<(), ApiError> {
|
||||
if is_authorized(state, headers) {
|
||||
Ok(())
|
||||
|
|
@ -53,6 +57,11 @@ pub async fn get_media_kit(State(state): State<AppState>) -> Result<Json<Value>,
|
|||
db::get_platforms(&conn).map_err(internal_error)?
|
||||
};
|
||||
|
||||
let collabs = {
|
||||
let conn = state.db.lock().unwrap();
|
||||
db::get_collabs(&conn).map_err(internal_error)?
|
||||
};
|
||||
|
||||
let twitch = match twitch::get_twitch(&state).await {
|
||||
Ok(value) => value,
|
||||
Err(message) => json!({
|
||||
|
|
@ -66,6 +75,7 @@ pub async fn get_media_kit(State(state): State<AppState>) -> Result<Json<Value>,
|
|||
let payload = json!({
|
||||
"content": content,
|
||||
"platforms": platforms,
|
||||
"collabs": collabs,
|
||||
"twitch": twitch,
|
||||
});
|
||||
|
||||
|
|
@ -93,10 +103,12 @@ pub async fn admin_get_state(
|
|||
let conn = state.db.lock().unwrap();
|
||||
let content = db::get_content(&conn).map_err(internal_error)?;
|
||||
let platforms = db::get_platforms(&conn).map_err(internal_error)?;
|
||||
let collabs = db::get_collabs(&conn).map_err(internal_error)?;
|
||||
|
||||
Ok(Json(json!({
|
||||
"content": content,
|
||||
"platforms": platforms,
|
||||
"collabs": collabs,
|
||||
})))
|
||||
}
|
||||
|
||||
|
|
@ -142,3 +154,39 @@ pub async fn admin_update_platforms(
|
|||
Ok(Json(json!({ "platforms": saved })))
|
||||
}
|
||||
|
||||
pub async fn admin_update_collabs(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
Json(collabs): Json<Vec<CollabInput>>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
require_admin(&state, &headers)?;
|
||||
|
||||
let mut cleaned = Vec::with_capacity(collabs.len());
|
||||
for collab in collabs {
|
||||
let name = collab.name.trim().to_string();
|
||||
let image = collab.image.trim().to_string();
|
||||
let url = collab.url.trim().to_string();
|
||||
|
||||
if name.is_empty() && image.is_empty() && url.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if name.is_empty() {
|
||||
return Err(bad_request("Each collaboration needs a name"));
|
||||
}
|
||||
|
||||
cleaned.push(CollabInput { name, image, url });
|
||||
}
|
||||
|
||||
{
|
||||
let mut conn = state.db.lock().unwrap();
|
||||
db::replace_collabs(&mut conn, &cleaned).map_err(internal_error)?;
|
||||
}
|
||||
|
||||
state.cache.invalidate(MEDIA_KIT_CACHE_KEY);
|
||||
|
||||
let conn = state.db.lock().unwrap();
|
||||
let saved = db::get_collabs(&conn).map_err(internal_error)?;
|
||||
|
||||
Ok(Json(json!({ "collabs": saved })))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,13 @@ pub struct PlatformInput {
|
|||
pub followers: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CollabInput {
|
||||
pub name: String,
|
||||
pub image: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
pub fn init(conn: &Connection) -> rusqlite::Result<()> {
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
|
|
@ -28,6 +35,15 @@ pub fn init(conn: &Connection) -> rusqlite::Result<()> {
|
|||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collabs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
image TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
"#,
|
||||
)?;
|
||||
seed(conn)
|
||||
|
|
@ -72,6 +88,35 @@ fn seed(conn: &Connection) -> rusqlite::Result<()> {
|
|||
)?;
|
||||
}
|
||||
|
||||
let collab_count: i64 = conn.query_row("SELECT COUNT(*) FROM collabs", [], |row| row.get(0))?;
|
||||
if collab_count == 0 {
|
||||
let collab_defaults = [
|
||||
(
|
||||
"ProtonPass",
|
||||
"/images/collaborations/proton-pass.webp",
|
||||
"https://proton.me/pass",
|
||||
),
|
||||
(
|
||||
"ProtonVPN",
|
||||
"/images/collaborations/proton-vpn.webp",
|
||||
"https://protonvpn.com/",
|
||||
),
|
||||
("Brick", "/images/collaborations/brick.webp", "https://getbrick.com/"),
|
||||
(
|
||||
"Focumon",
|
||||
"/images/collaborations/focumon.webp",
|
||||
"https://www.focumon.com/landing",
|
||||
),
|
||||
];
|
||||
|
||||
for (index, (name, image, url)) in collab_defaults.iter().enumerate() {
|
||||
conn.execute(
|
||||
"INSERT INTO collabs (name, image, url, sort_order, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![name, image, url, index as i64, now],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +165,47 @@ pub fn upsert_content(conn: &Connection, entries: &[(String, String)]) -> rusqli
|
|||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_collabs(conn: &Connection) -> rusqlite::Result<Vec<Value>> {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, name, image, url FROM collabs ORDER BY sort_order, id")?;
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(json!({
|
||||
"id": row.get::<_, i64>(0)?,
|
||||
"name": row.get::<_, String>(1)?,
|
||||
"image": row.get::<_, String>(2)?,
|
||||
"url": row.get::<_, String>(3)?,
|
||||
}))
|
||||
})?;
|
||||
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn replace_collabs(conn: &mut Connection, collabs: &[CollabInput]) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM collabs", [])?;
|
||||
|
||||
let now = now_secs();
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO collabs (name, image, url, sort_order, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
)?;
|
||||
|
||||
for (index, collab) in collabs.iter().enumerate() {
|
||||
stmt.execute(params![
|
||||
collab.name,
|
||||
collab.image,
|
||||
collab.url,
|
||||
index as i64,
|
||||
now
|
||||
])?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn upsert_platforms(conn: &Connection, platforms: &[PlatformInput]) -> rusqlite::Result<()> {
|
||||
let now = now_secs();
|
||||
let mut stmt = conn.prepare(
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ async fn main() {
|
|||
.route("/api/admin/state", get(api::admin_get_state))
|
||||
.route("/api/admin/content", put(api::admin_update_content))
|
||||
.route("/api/admin/platforms", put(api::admin_update_platforms))
|
||||
.route("/api/admin/collabs", put(api::admin_update_collabs))
|
||||
.fallback_service(ServeDir::new(&static_dir).append_index_html_on_directories(true))
|
||||
.with_state(app_state);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,19 +1,26 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Media kit and audience stats for RythonDev."
|
||||
/>
|
||||
<title>RythonDev — Media Kit</title>
|
||||
</head>
|
||||
<body
|
||||
class="bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-50"
|
||||
>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Media kit and audience stats for RythonDev."
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Rython, developer, frontend, web developer, streamer, coworking"
|
||||
/>
|
||||
<meta name="theme-color" content="#54E9FA" />
|
||||
<meta name="twitter:image" content="/images/preview.webp" />
|
||||
<title>RythonDev's Media Kit</title>
|
||||
</head>
|
||||
<body
|
||||
class="bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-50"
|
||||
>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
BIN
frontend/public/images/preview.webp
Normal file
BIN
frontend/public/images/preview.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
|
|
@ -1,32 +1,8 @@
|
|||
<script lang="ts">
|
||||
import AnimatedTooltip from "./ui/AnimatedTooltip/AnimatedTooltip.svelte";
|
||||
import type { Collab } from "../lib/types";
|
||||
import AnimatedTooltip from "./ui/AnimatedTooltip/AnimatedTooltip.svelte";
|
||||
|
||||
const people = [
|
||||
{
|
||||
id: 1,
|
||||
name: "ProtonPass",
|
||||
image: "/images/collaborations/proton-pass.webp",
|
||||
url: "https://proton.me/pass",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "ProtonVPN",
|
||||
image: "/images/collaborations/proton-vpn.webp",
|
||||
url: "https://protonvpn.com/",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Brick",
|
||||
image: "/images/collaborations/brick.webp",
|
||||
url: "https://getbrick.com/",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Focumon",
|
||||
image: "/images/collaborations/focumon.webp",
|
||||
url: "https://www.focumon.com/landing",
|
||||
},
|
||||
];
|
||||
let { items = [] }: { items?: Collab[] } = $props();
|
||||
</script>
|
||||
|
||||
<AnimatedTooltip items={people} />
|
||||
<AnimatedTooltip {items} />
|
||||
|
|
|
|||
10
frontend/src/components/media-kit/About.svelte
Normal file
10
frontend/src/components/media-kit/About.svelte
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
let { about = "" }: { about?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="mt-5">
|
||||
<div class="mb-2 text-3xl font-bold text-white sm:text-4xl">About Me</div>
|
||||
<p class="wrap-break-word">
|
||||
{about}
|
||||
</p>
|
||||
</div>
|
||||
23
frontend/src/components/media-kit/Intro.svelte
Normal file
23
frontend/src/components/media-kit/Intro.svelte
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
let {
|
||||
name = "RythonDev",
|
||||
tagline = "",
|
||||
}: {
|
||||
name?: string;
|
||||
tagline?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full translate-y-2 rounded-lg border-2 border-cyan-300 sm:w-fit">
|
||||
<div
|
||||
class="-translate-y-2 rounded-xl bg-cyan-300 px-5 py-5 text-3xl text-black sm:-translate-x-8 sm:text-4xl lg:-translate-x-20"
|
||||
>
|
||||
Hi there, I'm <br />
|
||||
<b>{name}</b>
|
||||
</div>
|
||||
{#if tagline}
|
||||
<p class="px-5 pb-2 text-xl text-cyan-300">
|
||||
{tagline}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
46
frontend/src/components/media-kit/PlatformCard.svelte
Normal file
46
frontend/src/components/media-kit/PlatformCard.svelte
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<script lang="ts">
|
||||
import Card from "../Card.svelte";
|
||||
import { formatCount } from "../../lib/format";
|
||||
import { metricLabel, platformImageMap } from "../../lib/platforms";
|
||||
|
||||
let {
|
||||
name,
|
||||
handle,
|
||||
href,
|
||||
followers,
|
||||
}: {
|
||||
name: string;
|
||||
handle: string;
|
||||
href: string;
|
||||
followers?: number | null;
|
||||
} = $props();
|
||||
|
||||
let deco = $derived(platformImageMap[name.toLowerCase()]);
|
||||
</script>
|
||||
|
||||
{#if deco}
|
||||
<Card
|
||||
className="flex flex-col gap-1 rounded-2xl border p-5 transition-colors border-zinc-800 bg-neutral-900"
|
||||
{href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color={deco.color}
|
||||
>
|
||||
<div class="flex flex-row items-start justify-start gap-3">
|
||||
<img src={deco.image} alt={name} width="40" />
|
||||
<div>
|
||||
<span
|
||||
class="text-sm font-semibold tracking-[0.08em] text-white uppercase"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-base">{handle}</span>
|
||||
<div class="mt-2 text-2xl font-semibold tracking-tight">
|
||||
{formatCount(followers)}
|
||||
<span class="text-sm">{metricLabel(name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/if}
|
||||
29
frontend/src/components/media-kit/Platforms.svelte
Normal file
29
frontend/src/components/media-kit/Platforms.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import type { Platform, TwitchData } from "../../lib/types";
|
||||
import PlatformCard from "./PlatformCard.svelte";
|
||||
|
||||
let {
|
||||
platforms = [],
|
||||
twitch = null,
|
||||
}: {
|
||||
platforms?: Platform[];
|
||||
twitch?: TwitchData | null;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<PlatformCard
|
||||
name="Twitch"
|
||||
handle={twitch?.display_name ?? ""}
|
||||
href={`https://twitch.tv/${twitch?.login ?? ""}`}
|
||||
followers={twitch?.followers}
|
||||
/>
|
||||
{#each platforms as platform (platform.name)}
|
||||
<PlatformCard
|
||||
name={platform.name}
|
||||
handle={platform.handle}
|
||||
href={platform.url}
|
||||
followers={platform.followers}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
18
frontend/src/components/media-kit/ProfileImage.svelte
Normal file
18
frontend/src/components/media-kit/ProfileImage.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
let {
|
||||
src,
|
||||
alt,
|
||||
class: className = "",
|
||||
}: {
|
||||
src: string;
|
||||
alt: string;
|
||||
class?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<img
|
||||
class="h-auto w-full max-w-75 rounded-xl object-cover shadow-lg shadow-cyan-300 {className}"
|
||||
{src}
|
||||
{alt}
|
||||
width="300"
|
||||
/>
|
||||
29
frontend/src/components/media-kit/Services.svelte
Normal file
29
frontend/src/components/media-kit/Services.svelte
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
let { services = "" }: { services?: string } = $props();
|
||||
|
||||
let items = $derived(
|
||||
services
|
||||
.split("\n")
|
||||
.map((service) => service.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 6),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if items.length}
|
||||
<section class="w-full">
|
||||
<div class="mb-3 text-3xl font-bold text-white sm:text-4xl">Services</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-x-8">
|
||||
<ul class="list-disc space-y-2 pl-5 marker:text-cyan-300">
|
||||
{#each items.slice(0, 3) as service}
|
||||
<li>{service}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<ul class="list-disc space-y-2 pl-5 marker:text-cyan-300">
|
||||
{#each items.slice(3, 6) as service}
|
||||
<li>{service}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { AdminState, MediaKit, Platform } from "./types";
|
||||
import type { AdminState, Collab, MediaKit, Platform } from "./types";
|
||||
|
||||
const TOKEN_KEY = "rythondev_admin_token";
|
||||
|
||||
|
|
@ -84,3 +84,27 @@ export async function updatePlatforms(
|
|||
return data.platforms;
|
||||
}
|
||||
|
||||
async function readError(res: Response, fallback: string): Promise<string> {
|
||||
const data = await res.json().catch(() => null);
|
||||
return typeof data?.error === "string" ? data.error : fallback;
|
||||
}
|
||||
|
||||
export async function updateCollabs(collabs: Collab[]): Promise<Collab[]> {
|
||||
const res = await fetch("/api/admin/collabs", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(
|
||||
collabs.map(({ name, image, url }) => ({ name, image, url })),
|
||||
),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
clearStoredToken();
|
||||
throw new Error("unauthorized");
|
||||
}
|
||||
if (!res.ok) throw new Error(await readError(res, "Failed to save collaborations"));
|
||||
|
||||
const data = await res.json();
|
||||
return data.collabs;
|
||||
}
|
||||
|
||||
|
|
|
|||
12
frontend/src/lib/format.ts
Normal file
12
frontend/src/lib/format.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export function formatCount(value: number | undefined | null): string {
|
||||
if (value == null) return "—";
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000;
|
||||
return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const thousands = value / 1_000;
|
||||
return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}K`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
27
frontend/src/lib/platforms.ts
Normal file
27
frontend/src/lib/platforms.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export type PlatformDeco = {
|
||||
image: string;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
export const platformImageMap: Record<string, PlatformDeco> = {
|
||||
twitch: {
|
||||
image: "/images/logos/twitch.svg",
|
||||
color: "#8E60F5",
|
||||
},
|
||||
youtube: {
|
||||
image: "/images/logos/youtube.svg",
|
||||
color: "#FF0000",
|
||||
},
|
||||
instagram: {
|
||||
image: "/images/logos/instagram.svg",
|
||||
color: "#E1306C",
|
||||
},
|
||||
tiktok: {
|
||||
image: "/images/logos/tiktok.svg",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
|
||||
export function metricLabel(name: string): string {
|
||||
return name.toLowerCase() === "youtube" ? "subscribers" : "followers";
|
||||
}
|
||||
|
|
@ -5,6 +5,13 @@ export interface Platform {
|
|||
followers: number;
|
||||
}
|
||||
|
||||
export interface Collab {
|
||||
id: number;
|
||||
name: string;
|
||||
image: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface TwitchData {
|
||||
configured?: boolean;
|
||||
found?: boolean;
|
||||
|
|
@ -23,10 +30,12 @@ export interface TwitchData {
|
|||
export interface MediaKit {
|
||||
content: Record<string, string>;
|
||||
platforms: Platform[];
|
||||
collabs: Collab[];
|
||||
twitch: TwitchData | null;
|
||||
}
|
||||
|
||||
export interface AdminState {
|
||||
content: Record<string, string>;
|
||||
platforms: Platform[];
|
||||
collabs: Collab[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@
|
|||
clearStoredToken,
|
||||
fetchAdminState,
|
||||
getStoredToken,
|
||||
updateCollabs,
|
||||
updateContent,
|
||||
updatePlatforms,
|
||||
verifyAdmin,
|
||||
} from "../lib/api";
|
||||
import type { Platform } from "../lib/types";
|
||||
import type { Collab, Platform } from "../lib/types";
|
||||
|
||||
let authed = $state(false);
|
||||
let loading = $state(true);
|
||||
|
|
@ -19,6 +20,8 @@
|
|||
|
||||
let content = $state<Record<string, string>>({});
|
||||
let platforms = $state<Platform[]>([]);
|
||||
let collabs = $state<Collab[]>([]);
|
||||
let nextCollabId = -1;
|
||||
|
||||
onMount(async () => {
|
||||
if (getStoredToken()) {
|
||||
|
|
@ -42,6 +45,7 @@
|
|||
const state = await fetchAdminState();
|
||||
content = state.content;
|
||||
platforms = state.platforms;
|
||||
collabs = state.collabs ?? [];
|
||||
authed = true;
|
||||
} catch (err) {
|
||||
authed = false;
|
||||
|
|
@ -72,6 +76,7 @@
|
|||
authed = false;
|
||||
content = {};
|
||||
platforms = [];
|
||||
collabs = [];
|
||||
message = "";
|
||||
error = "";
|
||||
}
|
||||
|
|
@ -88,6 +93,39 @@
|
|||
}
|
||||
}
|
||||
|
||||
function addCollab() {
|
||||
collabs = [
|
||||
...collabs,
|
||||
{ id: nextCollabId--, name: "", image: "", url: "" },
|
||||
];
|
||||
}
|
||||
|
||||
function removeCollab(index: number) {
|
||||
collabs = collabs.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function moveCollab(index: number, direction: -1 | 1) {
|
||||
const target = index + direction;
|
||||
if (target < 0 || target >= collabs.length) return;
|
||||
const next = [...collabs];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
collabs = next;
|
||||
}
|
||||
|
||||
async function handleSaveCollabs() {
|
||||
error = "";
|
||||
message = "";
|
||||
try {
|
||||
collabs = await updateCollabs(collabs);
|
||||
message = "Collaborations saved";
|
||||
} catch (err) {
|
||||
error =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Unable to save collaborations";
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavePlatforms() {
|
||||
error = "";
|
||||
message = "";
|
||||
|
|
@ -296,20 +334,136 @@
|
|||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="border-t border-gray-200 pt-8 md:col-span-2 dark:border-zinc-800"
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold tracking-tight">
|
||||
Collaborations
|
||||
</h2>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="cursor-pointer rounded-[10px] border border-gray-200 bg-transparent px-4 py-2.5 text-sm font-medium text-neutral-900 transition-opacity hover:opacity-85 dark:border-zinc-800 dark:text-neutral-50"
|
||||
onclick={addCollab}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer rounded-[10px] border border-transparent bg-neutral-900 px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-85 dark:bg-neutral-50 dark:text-neutral-950"
|
||||
onclick={handleSaveCollabs}
|
||||
>
|
||||
Save collaborations
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if collabs.length === 0}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
No collaborations yet.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-5">
|
||||
{#each collabs as collab, index (collab.id)}
|
||||
<div
|
||||
class="rounded-2xl border border-gray-200 bg-neutral-50 p-5 dark:border-zinc-800 dark:bg-neutral-900"
|
||||
>
|
||||
<div
|
||||
class="mb-4 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div class="text-sm font-semibold">
|
||||
{collab.name || "New collaboration"}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="cursor-pointer rounded-[10px] border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-800 dark:text-neutral-50"
|
||||
disabled={index === 0}
|
||||
onclick={() => moveCollab(index, -1)}
|
||||
>
|
||||
Up
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer rounded-[10px] border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-neutral-900 disabled:cursor-not-allowed disabled:opacity-40 dark:border-zinc-800 dark:text-neutral-50"
|
||||
disabled={index === collabs.length - 1}
|
||||
onclick={() => moveCollab(index, 1)}
|
||||
>
|
||||
Down
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer rounded-[10px] border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-red-600 dark:border-zinc-800 dark:text-red-400"
|
||||
onclick={() => removeCollab(index)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-3"
|
||||
>
|
||||
<label
|
||||
class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span>Name</span>
|
||||
<input
|
||||
class="w-full rounded-[10px] border border-gray-200 bg-white px-3 py-2.5 text-neutral-900 outline-none focus:border-violet-600 dark:border-zinc-800 dark:bg-neutral-950 dark:text-neutral-50 dark:focus:border-violet-400"
|
||||
bind:value={collab.name}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span>Image URL</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
class="w-full rounded-[10px] border border-gray-200 bg-white px-3 py-2.5 text-neutral-900 outline-none focus:border-violet-600 dark:border-zinc-800 dark:bg-neutral-950 dark:text-neutral-50 dark:focus:border-violet-400"
|
||||
bind:value={collab.image}
|
||||
/>
|
||||
{#if collab.image}
|
||||
<img
|
||||
class="h-10 w-10 shrink-0 rounded-lg border border-gray-200 object-cover dark:border-zinc-800"
|
||||
src={collab.image}
|
||||
alt=""
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
class="flex flex-col gap-2 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span>URL</span>
|
||||
<input
|
||||
class="w-full rounded-[10px] border border-gray-200 bg-white px-3 py-2.5 text-neutral-900 outline-none focus:border-violet-600 dark:border-zinc-800 dark:bg-neutral-950 dark:text-neutral-50 dark:focus:border-violet-400"
|
||||
bind:value={collab.url}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if message}
|
||||
{#if message || error}
|
||||
<div
|
||||
class="mt-6 rounded-[10px] bg-green-600/10 px-4 py-3 text-sm text-green-600 dark:text-green-400"
|
||||
class="fixed right-6 bottom-6 z-50 flex max-w-sm flex-col gap-2"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div
|
||||
class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
{#if message}
|
||||
<div
|
||||
class="rounded-[10px] bg-green-600 px-4 py-3 text-sm font-medium text-white shadow-lg"
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div
|
||||
class="rounded-[10px] bg-red-600 px-4 py-3 text-sm font-medium text-white shadow-lg"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@
|
|||
import { onMount } from "svelte";
|
||||
import { fetchMediaKit } from "../lib/api";
|
||||
import type { MediaKit } from "../lib/types";
|
||||
import Card from "../components/Card.svelte";
|
||||
import Collabs from "../components/Collabs.svelte";
|
||||
import About from "../components/media-kit/About.svelte";
|
||||
import Intro from "../components/media-kit/Intro.svelte";
|
||||
import Platforms from "../components/media-kit/Platforms.svelte";
|
||||
import ProfileImage from "../components/media-kit/ProfileImage.svelte";
|
||||
import Services from "../components/media-kit/Services.svelte";
|
||||
|
||||
let data = $state<MediaKit | null>(null);
|
||||
let error = $state("");
|
||||
|
|
@ -18,51 +22,8 @@
|
|||
|
||||
let content = $derived(data?.content ?? {});
|
||||
let platforms = $derived(data?.platforms ?? []);
|
||||
let services = $derived(
|
||||
(content.services ?? "")
|
||||
.split("\n")
|
||||
.map((service) => service.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 6),
|
||||
);
|
||||
let collabs = $derived(data?.collabs ?? []);
|
||||
let twitch = $derived(data?.twitch);
|
||||
// let twitchLogin = $derived(twitch?.login ?? "");
|
||||
|
||||
type platformDecoType = {
|
||||
image: string;
|
||||
color?: string | null;
|
||||
};
|
||||
|
||||
let platformImageMap: Record<string, platformDecoType> = {
|
||||
twitch: {
|
||||
image: "/images/logos/twitch.svg",
|
||||
color: "#8E60F5",
|
||||
},
|
||||
youtube: {
|
||||
image: "/images/logos/youtube.svg",
|
||||
color: "#FF0000",
|
||||
},
|
||||
instagram: {
|
||||
image: "/images/logos/instagram.svg",
|
||||
color: "#E1306C",
|
||||
},
|
||||
tiktok: {
|
||||
image: "/images/logos/tiktok.svg",
|
||||
color: "#FFFFFF",
|
||||
},
|
||||
};
|
||||
function formatCount(value: number | undefined | null): string {
|
||||
if (value == null) return "—";
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000;
|
||||
return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const thousands = value / 1_000;
|
||||
return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}K`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
|
|
@ -83,11 +44,9 @@
|
|||
>
|
||||
{#if content.profile_image_url}
|
||||
<div class="flex justify-center lg:hidden">
|
||||
<img
|
||||
class="h-auto w-full max-w-75 rounded-xl object-cover shadow-lg shadow-cyan-300"
|
||||
<ProfileImage
|
||||
src={content.profile_image_url}
|
||||
alt={content.brand_name ?? "Profile image"}
|
||||
width="300"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -95,150 +54,29 @@
|
|||
<div class="lg:px-5">
|
||||
<div class="hidden items-start justify-end lg:flex">
|
||||
{#if content.profile_image_url}
|
||||
<img
|
||||
class="h-auto w-full max-w-75 shrink-0 rounded-xl object-cover shadow-lg shadow-cyan-300"
|
||||
<ProfileImage
|
||||
src={content.profile_image_url}
|
||||
alt={content.brand_name ?? "Profile image"}
|
||||
width="300"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<!-- Twitch -->
|
||||
<Card
|
||||
className="flex flex-col gap-1 rounded-2xl border p-5 transition-colors border-zinc-800 bg-neutral-900"
|
||||
href={`https://twitch.tv/${twitch?.login}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color={platformImageMap["twitch"].color}
|
||||
>
|
||||
<div
|
||||
class="flex flex-row justify-start items-start gap-3"
|
||||
>
|
||||
<img
|
||||
src={platformImageMap["twitch"].image}
|
||||
alt="Twitch"
|
||||
width="40"
|
||||
/>
|
||||
<div>
|
||||
<span
|
||||
class="text-sm font-semibold tracking-[0.08em] uppercase text-white"
|
||||
>
|
||||
Twitch
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-base"
|
||||
>{twitch?.display_name}</span
|
||||
>
|
||||
<div
|
||||
class="mt-2 text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{formatCount(twitch?.followers)}
|
||||
<span class="text-sm">followers</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{#each platforms as platform (platform.name)}
|
||||
<Card
|
||||
className="flex flex-col gap-1 rounded-2xl border p-5 transition-colors border-zinc-800 bg-neutral-900"
|
||||
href={platform.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
color={platformImageMap[platform.name]?.color}
|
||||
>
|
||||
<div
|
||||
class="flex flex-row justify-start items-start gap-3"
|
||||
>
|
||||
<img
|
||||
src={platformImageMap[platform.name].image}
|
||||
alt="Twitch"
|
||||
width="48"
|
||||
/>
|
||||
<div>
|
||||
<span
|
||||
class="text-sm font-semibold tracking-[0.08em] uppercase text-white"
|
||||
>
|
||||
{platform.name}
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-base"
|
||||
>{platform.handle}</span
|
||||
>
|
||||
<div
|
||||
class="mt-2 text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{formatCount(platform.followers)}
|
||||
<span class="text-sm">
|
||||
{#if platform.name != "youtube"}
|
||||
followers
|
||||
{:else}
|
||||
subscribers
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/each}
|
||||
</div>
|
||||
<Platforms {platforms} {twitch} />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 2nd column -->
|
||||
<div class="order-1 flex min-w-0 flex-col items-start justify-start gap-5 lg:order-2">
|
||||
<div
|
||||
class="w-full translate-y-2 rounded-lg border-2 border-cyan-300 sm:w-fit"
|
||||
>
|
||||
<div
|
||||
class="-translate-y-2 rounded-xl bg-cyan-300 px-5 py-5 text-3xl text-black sm:-translate-x-8 sm:text-4xl lg:-translate-x-20"
|
||||
>
|
||||
Hi there, I'm <br />
|
||||
<b>RythonDev</b>
|
||||
</div>
|
||||
{#if content.tagline}
|
||||
<p class="text-xl px-5 text-cyan-300 pb-2">
|
||||
{content.tagline}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div class="mb-2 text-3xl font-bold text-white sm:text-4xl">About Me</div>
|
||||
<p class="break-words">
|
||||
{content.about ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-2 text-3xl font-bold text-white sm:text-4xl">
|
||||
Collaborations
|
||||
</div>
|
||||
<Collabs />
|
||||
</div>
|
||||
{#if services.length}
|
||||
<section class="w-full">
|
||||
<div class="mb-3 text-3xl font-bold text-white sm:text-4xl">
|
||||
Services
|
||||
<div
|
||||
class="order-1 flex min-w-0 flex-col items-start justify-start gap-5 lg:order-2"
|
||||
>
|
||||
<Intro name="RythonDev" tagline={content.tagline ?? ""} />
|
||||
<About about={content.about ?? ""} />
|
||||
{#if collabs.length}
|
||||
<div>
|
||||
<div class="mb-2 text-3xl font-bold text-white sm:text-4xl">
|
||||
Collaborations
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-x-8">
|
||||
<ul
|
||||
class="list-disc space-y-2 pl-5 marker:text-cyan-300"
|
||||
>
|
||||
{#each services.slice(0, 3) as service}
|
||||
<li>{service}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<ul
|
||||
class="list-disc space-y-2 pl-5 marker:text-cyan-300"
|
||||
>
|
||||
{#each services.slice(3, 6) as service}
|
||||
<li>{service}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<Collabs items={collabs} />
|
||||
</div>
|
||||
{/if}
|
||||
<Services services={content.services ?? ""} />
|
||||
</div>
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,147 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fetchMediaKit } from "../lib/api";
|
||||
import type { MediaKit } from "../lib/types";
|
||||
|
||||
let data = $state<MediaKit | null>(null);
|
||||
let error = $state("");
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
data = await fetchMediaKit();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : "Something went wrong";
|
||||
}
|
||||
});
|
||||
|
||||
const content = $derived(data?.content ?? {});
|
||||
const platforms = $derived(data?.platforms ?? []);
|
||||
const twitch = $derived(data?.twitch ?? null);
|
||||
const twitchLogin = $derived(twitch?.login ?? "");
|
||||
|
||||
function formatCount(value: number | undefined | null): string {
|
||||
if (value == null) return "—";
|
||||
if (value >= 1_000_000) {
|
||||
const millions = value / 1_000_000;
|
||||
return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
const thousands = value / 1_000;
|
||||
return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1).replace(/\.0$/, "")}K`;
|
||||
}
|
||||
return value.toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
|
||||
<div
|
||||
class="mt-6 rounded-[10px] bg-red-600/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
</main>
|
||||
{:else if !data}
|
||||
<main class="mx-auto w-full max-w-230 px-6 pt-12 pb-18">
|
||||
<div class="py-8 text-gray-500 dark:text-gray-400">Loading…</div>
|
||||
</main>
|
||||
{:else}
|
||||
<main class="grid grid-cols-2">
|
||||
<div
|
||||
class="flex flex-col items-start gap-6 pt-6 pb-2 sm:flex-row sm:items-center"
|
||||
>
|
||||
{#if content.profile_image_url}
|
||||
<img
|
||||
class="h-24 w-24 shrink-0 rounded-md object-cover"
|
||||
src={content.profile_image_url}
|
||||
alt={content.brand_name ?? "Profile image"}
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<h1
|
||||
class="text-[clamp(36px,8vw,52px)] leading-[1.05] font-semibold tracking-tight"
|
||||
>
|
||||
{content.brand_name ?? "RythonDev"}
|
||||
</h1>
|
||||
{#if content.tagline}
|
||||
<p class="mt-4 text-xl text-gray-500 dark:text-gray-400">
|
||||
{content.tagline}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800"
|
||||
>
|
||||
<h2 class="text-xl font-semibold tracking-tight">About</h2>
|
||||
<p
|
||||
class="mt-4 max-w-160 text-lg leading-relaxed text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{content.about ?? ""}
|
||||
</p>
|
||||
{#if content.email}
|
||||
<a
|
||||
class="mt-4 inline-block text-violet-600 dark:text-violet-400"
|
||||
href="mailto:{content.email}">{content.email}</a
|
||||
>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800"
|
||||
>
|
||||
<h2 class="text-xl font-semibold tracking-tight">Twitch</h2>
|
||||
{#if twitch?.configured && twitch?.found}
|
||||
<div
|
||||
class="mt-6 grid grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-4"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-2xl border border-gray-200 bg-neutral-50 p-5 dark:border-zinc-800 dark:bg-neutral-900"
|
||||
>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400"
|
||||
>Followers</span
|
||||
>
|
||||
<span class="text-3xl font-semibold tracking-tight"
|
||||
>{formatCount(twitch?.followers)}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-gray-500 dark:text-gray-400">
|
||||
{twitch?.message ?? "Twitch stats are unavailable."}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section
|
||||
class="mt-14 border-t border-gray-200 pt-8 dark:border-zinc-800"
|
||||
>
|
||||
<h2 class="text-xl font-semibold tracking-tight">Platforms</h2>
|
||||
<div
|
||||
class="mt-6 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-4"
|
||||
>
|
||||
{#each platforms as platform (platform.name)}
|
||||
<a
|
||||
class="flex flex-col gap-1 rounded-2xl border border-gray-200 bg-neutral-50 p-5 transition-colors hover:border-violet-600 dark:border-zinc-800 dark:bg-neutral-900 dark:hover:border-violet-400"
|
||||
href={platform.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-semibold tracking-[0.08em] uppercase text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{platform.name}
|
||||
</span>
|
||||
<span class="text-base">{platform.handle}</span>
|
||||
<span
|
||||
class="mt-2 text-2xl font-semibold tracking-tight"
|
||||
>
|
||||
{formatCount(platform.followers)} followers
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{/if}
|
||||
Loading…
Reference in a new issue