add the visitor API endpoint

This commit is contained in:
ngn 2025-01-08 00:20:11 +03:00
parent fc11748e57
commit dee3ef4d85
26 changed files with 444 additions and 163 deletions

View File

@ -301,7 +301,7 @@ commands = [
"check_services", "check_services",
"add_news", "add_news",
"del_news", "del_news",
"logs" "logs",
] ]
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -17,6 +17,18 @@ func (db *Type) Load() (err error) {
return fmt.Errorf("cannot access the database: %s", err.Error()) return fmt.Errorf("cannot access the database: %s", err.Error())
} }
// see database/visitor.go
_, err = db.sql.Exec(`
CREATE TABLE IF NOT EXISTS visitor_count(
id TEXT NOT NULL UNIQUE,
count INTEGER NOT NULL
);
`)
if err != nil {
return fmt.Errorf("failed to create the visitor_count table: %s", err.Error())
}
// see database/service.go // see database/service.go
_, err = db.sql.Exec(` _, err = db.sql.Exec(`
CREATE TABLE IF NOT EXISTS services( CREATE TABLE IF NOT EXISTS services(

48
api/database/visitor.go Normal file
View File

@ -0,0 +1,48 @@
package database
import (
"database/sql"
)
func (db *Type) VisitorGet() (uint64, error) {
var (
row *sql.Row
count uint64
err error
)
if row = db.sql.QueryRow("SELECT count FROM visitor_count WHERE id = 0"); row == nil {
return 0, nil
}
if err = row.Scan(&count); err != nil && err != sql.ErrNoRows {
return 0, err
}
if err == sql.ErrNoRows {
return 0, nil
}
return count, nil
}
func (db *Type) VisitorIncrement() (err error) {
if _, err = db.sql.Exec("UPDATE visitor_count SET count = count + 1 WHERE id = 0"); err != nil && err != sql.ErrNoRows {
return err
}
// TODO: err is always nil even if there is no rows for some reason, check sql.Result instead
if err == sql.ErrNoRows {
_, err = db.sql.Exec(
`INSERT INTO visitor_count(
id, count
) values(?, ?)`,
0, 0,
)
return err
}
return nil
}

View File

@ -89,6 +89,7 @@ func main() {
// v1 user routes // v1 user routes
v1.Get("/services", routes.GET_Services) v1.Get("/services", routes.GET_Services)
v1.Get("/visitor", routes.GET_Visitor)
v1.Get("/news/:lang", routes.GET_News) v1.Get("/news/:lang", routes.GET_News)
// v1 admin routes // v1 admin routes

50
api/routes/visitor.go Normal file
View File

@ -0,0 +1,50 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/ngn13/website/api/database"
"github.com/ngn13/website/api/util"
)
const LAST_ADDRS_MAX = 30
var last_addrs []string
func GET_Visitor(c *fiber.Ctx) error {
var (
err error
count uint64
)
db := c.Locals("database").(*database.Type)
new_addr := util.GetSHA1(util.IP(c))
for _, addr := range last_addrs {
if new_addr == addr {
if count, err = db.VisitorGet(); err != nil {
return util.ErrInternal(c, err)
}
return util.JSON(c, 200, fiber.Map{
"result": count,
})
}
}
if err = db.VisitorIncrement(); err != nil {
return util.ErrInternal(c, err)
}
if count, err = db.VisitorGet(); err != nil {
return util.ErrInternal(c, err)
}
if len(last_addrs) > LAST_ADDRS_MAX {
last_addrs = append(last_addrs[:0], last_addrs[1:]...)
last_addrs = append(last_addrs, new_addr)
}
return util.JSON(c, 200, fiber.Map{
"result": count,
})
}

View File

@ -3,7 +3,7 @@
"version": "6.0", "version": "6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "VITE_API_URL=http://127.0.0.1:7001 VITE_FRONTEND_URL=http://localhost:5173 vite dev", "dev": "VITE_BUG_REPORT_URL=https://github.com/ngn13/website/issues VITE_API_URL=http://127.0.0.1:7001 VITE_FRONTEND_URL=http://localhost:5173 vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview --host", "preview": "vite preview --host",
"lint": "prettier --check .", "lint": "prettier --check .",

View File

@ -1,21 +1,33 @@
import { urljoin } from "$lib/util.js";
const version = "v1"; const version = "v1";
const url = new URL(version + "/", import.meta.env.VITE_API_URL).href; const url = urljoin(import.meta.env.VITE_API_URL, version);
function join(path) { function api_url(path = null, query = {}) {
if (null === path || path === "") return url; return urljoin(url, path, query);
}
if (path[0] === "/") path = path.slice(1); function check_err(json) {
if (!("error" in json)) throw new Error('API response is missing the "error" key');
return new URL(path, url).href; if (json["error"] != "") throw new Error(`API returned an error: ${json["error"]}`);
if (!("result" in json)) throw new Error('API response is missing the "result" key');
}
async function GET(fetch, url) {
const res = await fetch(url);
const json = await res.json();
check_err(json);
return json["result"];
}
async function visitor(fetch) {
return GET(fetch, api_url("/visitor"));
} }
async function services(fetch) { async function services(fetch) {
const res = await fetch(join("/services")); return GET(fetch, api_url("/services"));
const json = await res.json();
if (!("result" in json)) return [];
return json.result;
} }
export { version, join, services }; export { version, api_url, visitor, services };

View File

@ -1,25 +0,0 @@
<script>
export let class_name;
</script>
<main class={class_name}>
<slot></slot>
</main>
<style>
main {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: stretch;
padding: 50px;
gap: 28px;
}
@media only screen and (max-width: 900px) {
main {
flex-direction: column;
}
}
</style>

61
app/src/lib/error.svelte Normal file
View File

@ -0,0 +1,61 @@
<script>
import Link from "$lib/link.svelte";
import { color } from "$lib/util.js";
import { _ } from "svelte-i18n";
export let error = "";
</script>
<main>
<h1 style="color: var(--{color()})">{$_("error.title")}</h1>
<code>
{#if error === ""}
Unknown error
{:else}
{error}
{/if}
</code>
<Link link={import.meta.env.VITE_BUG_REPORT_URL}>
{$_("error.report")}
</Link>
<img src="/profile/sad.png" alt="" />
</main>
<style>
main {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: end;
gap: 10px;
padding: 50px;
font-size: var(--size-4);
background: var(--background);
background-size: 50%;
}
main h1 {
font-size: var(--size-6);
}
main code {
font-size: var(--size-4);
color: var(--white-2);
}
main img {
width: var(--profile-size);
position: absolute;
right: 0;
bottom: 0;
}
</style>

View File

@ -1,13 +1,15 @@
<script> <script>
import Link from "$lib/link.svelte"; import Link from "$lib/link.svelte";
import { onMount } from "svelte";
import { visitor } from "$lib/api.js";
import { color } from "$lib/util.js"; import { color } from "$lib/util.js";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
let visitor_count = 1001; let visitor_count = 0;
function should_congrat() { onMount(async () => {
return visitor_count % 1000 == 0; visitor_count = await visitor(fetch);
} });
</script> </script>
<footer style="border-top: solid 2px var(--{color()});"> <footer style="border-top: solid 2px var(--{color()});">
@ -32,8 +34,8 @@
<div class="useless"> <div class="useless">
<span> <span>
{$_("footer.number", { values: { count: visitor_count } })} {$_("footer.number", { values: { count: visitor_count } })}
{#if should_congrat()} {#if visitor_count % 1000 == 0}
<span style="color: var(--{color()})">({$_("footer.congrats")})</span> <span style="color: var(--{color()})">({$_("footer.congrat")})</span>
{/if} {/if}
</span> </span>
<span> <span>

View File

@ -1,5 +1,7 @@
<script> <script>
import { frontend_url, api_url } from "$lib/util.js"; import { frontend_url } from "$lib/util.js";
import { api_url } from "$lib/api.js";
export let desc, title; export let desc, title;
</script> </script>

View File

@ -4,29 +4,23 @@
export let picture = ""; export let picture = "";
export let title = ""; export let title = "";
let current = "";
for (let i = 0; i < title.length; i++) {
setTimeout(
() => {
current += title[i];
},
100 * (i + 1)
);
}
</script> </script>
<header> <header>
<h1 style="color: var(--{color()})">{current}</h1> <div>
<h1 class="title" style="color: var(--{color()})">
{title.toLowerCase()}
</h1>
<h1 class="cursor" style="color: var(--{color()})">_</h1>
</div>
<img src="/profile/{picture}.png" alt="" /> <img src="/profile/{picture}.png" alt="" />
</header> </header>
<style> <style>
header { header {
background: linear-gradient(rgba(11, 11, 11, 0.808), rgba(1, 1, 1, 0.96)), background: var(--background);
url("https://files.ngn.tf/banner.png");
background-size: 50%; background-size: 50%;
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
@ -35,15 +29,12 @@
align-items: end; align-items: end;
} }
img { header div {
padding: 50px 50px 0 50px; display: flex;
width: 220px; flex-direction: row;
bottom: 0; align-items: end;
left: 0; padding: 50px 50px 30px 50px;
} font-size: var(--size-6);
h1 {
font-size: var(--size-7);
font-family: font-family:
Consolas, Consolas,
Monaco, Monaco,
@ -53,29 +44,38 @@
Bitstream Vera Sans Mono, Bitstream Vera Sans Mono,
Courier New, Courier New,
monospace; monospace;
padding: 50px 50px 30px 50px;
white-space: nowrap; white-space: nowrap;
text-align: center; justify-content: start;
color: white; width: min-content;
text-shadow: var(--text-shadow);
} }
h1::after { header div .title {
text-shadow: var(--text-shadow);
overflow: hidden;
width: 0;
animation: typing 1s steps(20, end) forwards;
animation-delay: 0.3s;
}
header div .cursor {
content: "_"; content: "_";
display: inline-block; display: inline-block;
animation: blink 1.5s steps(2) infinite; animation: blink 1.5s steps(2) infinite;
} }
header img {
padding: 50px 50px 0 50px;
width: var(--profile-size);
bottom: 0;
left: 0;
}
@media only screen and (max-width: 900px) { @media only screen and (max-width: 900px) {
header { header {
display: block; display: block;
} }
h1 { header img {
padding: 80px;
}
img {
display: none; display: none;
} }
} }

View File

@ -5,10 +5,13 @@
const default_color = "white-1"; const default_color = "white-1";
export let active = false; export let active = false;
export let highlight = true;
export let link = ""; export let link = "";
export let icon = ""; export let icon = "";
let style = `text-decoration-color: var(--${color()});`; let style = "";
if (highlight) style = `text-decoration-color: var(--${color()});`;
if (active) style += `color: var(--${color()});`; if (active) style += `color: var(--${color()});`;
else style += `color: var(--${default_color});`; else style += `color: var(--${default_color});`;
@ -17,6 +20,18 @@
{#if icon != ""} {#if icon != ""}
<Icon {icon} /> <Icon {icon} />
{/if} {/if}
{#if highlight}
<a {style} href={link}> <a {style} href={link}>
<slot></slot> <slot></slot>
</a> </a>
{:else}
<a {style} class="no-highlight" href={link}>
<slot></slot>
</a>
{/if}
<style>
.no-highlight:hover {
text-decoration: none;
}
</style>

View File

@ -3,7 +3,7 @@
import Link from "$lib/link.svelte"; import Link from "$lib/link.svelte";
import { color, time_from_ts } from "$lib/util.js"; import { color, time_from_ts } from "$lib/util.js";
import { locale } from "svelte-i18n"; import { _, locale } from "svelte-i18n";
export let service = {}; export let service = {};
let style = ""; let style = "";
@ -18,23 +18,35 @@
<p>{service.desc[$locale.slice(0, 2)]}</p> <p>{service.desc[$locale.slice(0, 2)]}</p>
</div> </div>
<div class="links"> <div class="links">
<Link link={service.clear}><Icon icon="nf-oct-link" /></Link> <Link highlight={false} link={service.clear}><Icon icon="nf-oct-link" /></Link>
{#if service.onion != ""} {#if service.onion != ""}
<Link link={service.onion}><Icon icon="nf-linux-tor" /></Link> <Link highlight={false} link={service.onion}><Icon icon="nf-linux-tor" /></Link>
{/if} {/if}
{#if service.i2p != ""} {#if service.i2p != ""}
<Link link={service.i2p}><span style="color: var(--{color()})">I2P</span></Link> <Link highlight={false} link={service.i2p}
><span style="color: var(--{color()})">I2P</span></Link
>
{/if} {/if}
</div> </div>
</div> </div>
<div class="check"> <div class="check">
<h1>Last checked at {time_from_ts(service.check_time)}</h1> <h1>
{$_("services.last", {
values: { time: time_from_ts(service.check_time) },
})}
</h1>
{#if service.check_res == 0} {#if service.check_res == 0}
<span style="background: var(--white-2)">Down</span> <span style="background: var(--white-2)">
{$_("services.status.down")}
</span>
{:else if service.check_res == 1} {:else if service.check_res == 1}
<span style="background: var(--{color()})">Up</span> <span style="background: var(--{color()})">
{$_("services.status.up")}
</span>
{:else if service.check_res == 2} {:else if service.check_res == 2}
<span style="background: var(--white-2)">Slow</span> <span style="background: var(--white-2)">
{$_("services.status.slow")}
</span>
{/if} {/if}
</div> </div>
</main> </main>

View File

@ -1,5 +1,6 @@
import { join } from "$lib/api.js"; import { browser } from "$app/environment";
const default_lang = "en";
const colors = [ const colors = [
"yellow", "yellow",
"cyan", "cyan",
@ -8,8 +9,26 @@ const colors = [
"red", "red",
// "blue" (looks kinda ass) // "blue" (looks kinda ass)
]; ];
let colors_pos = -1; let colors_pos = -1;
let api_url = join;
function urljoin(url, path = null, query = {}) {
let url_len = url.length;
if (url[url_len - 1] != "/") url += "/";
if (null === path || "" === path) url = new URL(url);
else if (path[0] === "/") url = new URL(path.slice(1), url);
else url = new URL(path, url);
for (let k in query) url.searchParams.append(query[k]);
return url.href;
}
function frontend_url(path = null, query = {}) {
return urljoin(import.meta.env.VITE_FRONTEND_URL, path, query);
}
function color() { function color() {
if (colors_pos < 0) colors_pos = Math.floor(Math.random() * colors.length); if (colors_pos < 0) colors_pos = Math.floor(Math.random() * colors.length);
@ -23,13 +42,13 @@ function click() {
audio.play(); audio.play();
} }
function frontend_url(path) { function browser_lang() {
if (null !== path && path !== "") return new URL(path, import.meta.env.VITE_FRONTEND_URL).href; if (browser) return window.navigator.language.slice(0, 2).toLowerCase();
else return new URL(import.meta.env.VITE_FRONTEND_URL).href; return default_lang;
} }
function time_from_ts(ts) { function time_from_ts(ts) {
return new Date(ts * 1000).toLocaleTimeString(); return new Date(ts * 1000).toLocaleTimeString();
} }
export { api_url, frontend_url, click, color, time_from_ts }; export { urljoin, frontend_url, browser_lang, click, color, time_from_ts };

View File

@ -6,7 +6,7 @@
"donate": "donate" "donate": "donate"
}, },
"home": { "home": {
"title": "hello world!", "title": "Hello world!",
"welcome": { "welcome": {
"title": "about", "title": "about",
"desc": "Welcome to my website, I'm ngn", "desc": "Welcome to my website, I'm ngn",
@ -37,6 +37,32 @@
"link": "see all the services" "link": "see all the services"
} }
}, },
"services": {
"title": "Service Status",
"search": "Search for a service",
"feed": "News and updates",
"last": "Last checked at {time}",
"status": {
"up": "Up",
"down": "Down",
"slow": "Slow"
}
},
"donate": {
"title": "donate money!",
"info": "I spend a lot of time working on different projects and maintaining different services.",
"price": "I mostly pay for hosting and electricity. Which when added up costs around 550₺ per month, that is Turkish Lira, equals to ~$15 at time of writing (ik the economy is great).",
"details": "So even a small donation would be highly appreciated and it would help me keep everything up and running.",
"thanks": "Also huge thanks to all of you who has donated so far, as I said, I highly appreciate it. Thank you!",
"table": {
"platform": "Platform",
"address": "Adress/Link"
}
},
"error": {
"title": "Something went wrong!",
"report": "report this issue"
},
"footer": { "footer": {
"source": "Source", "source": "Source",
"license": "License", "license": "License",

View File

@ -1,19 +1,29 @@
import { locale, waitLocale } from "svelte-i18n"; import { init, register, waitLocale } from "svelte-i18n";
import { init, register } from "svelte-i18n"; import { browser_lang } from "$lib/util.js";
import { browser } from "$app/environment"; import { services } from "$lib/api.js";
import languages from "$lib/lang.js"; import languages from "$lib/lang.js";
const defaultLocale = languages[0].code; // setup the locale
for (let i = 0; i < languages.length; i++) for (let i = 0; i < languages.length; i++)
register(languages[i].code, () => import(/* @vite-ignore */ languages[i].path)); register(languages[i].code, () => import(/* @vite-ignore */ languages[i].path));
init({ init({
fallbackLocale: defaultLocale, fallbackLocale: languages[0].code,
initialLocale: browser ? window.navigator.language.slice(0, 2).toLowerCase() : defaultLocale, initialLocale: browser_lang(),
}); });
export const load = async () => { // load locales & load data from the API
if (browser) locale.set(window.navigator.language); export async function load({ fetch }) {
await waitLocale(); await waitLocale();
try {
return {
services: await services(fetch),
error: null,
}; };
} catch (err) {
return {
error: err,
};
}
}

View File

@ -1,14 +1,21 @@
<script> <script>
import Navbar from "$lib/navbar.svelte"; import Navbar from "$lib/navbar.svelte";
import Footer from "$lib/footer.svelte"; import Footer from "$lib/footer.svelte";
import Error from "$lib/error.svelte";
let { data, children } = $props();
</script> </script>
<main> <main>
{#if data.error === null}
<Navbar /> <Navbar />
<div class="content"> <div class="content">
<slot></slot> {@render children()}
</div> </div>
<Footer /> <Footer />
{:else}
<Error error={data.error} />>
{/if}
</main> </main>
<style> <style>

View File

@ -1,6 +1,5 @@
<script> <script>
import Header from "$lib/header.svelte"; import Header from "$lib/header.svelte";
import Content from "$lib/content.svelte";
import Head from "$lib/head.svelte"; import Head from "$lib/head.svelte";
import Card from "$lib/card.svelte"; import Card from "$lib/card.svelte";
import Link from "$lib/link.svelte"; import Link from "$lib/link.svelte";
@ -10,9 +9,9 @@
</script> </script>
<Head title="home" desc="home page of my personal website" /> <Head title="home" desc="home page of my personal website" />
<Header title={$_("home.title")} picture="tired" /> <Header picture="tired" title={$_("home.title")} />
<Content> <main>
<Card title={$_("home.welcome.title")}> <Card title={$_("home.welcome.title")}>
<span> 👋 {$_("home.welcome.desc")}</span> <span> 👋 {$_("home.welcome.desc")}</span>
<ul> <ul>
@ -77,9 +76,19 @@
</div> </div>
</div> </div>
</Card> </Card>
</Content> </main>
<style> <style>
main {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: stretch;
padding: 50px;
gap: 28px;
}
.prefer { .prefer {
color: var(--white-2); color: var(--white-2);
font-style: italic; font-style: italic;
@ -98,4 +107,10 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@media only screen and (max-width: 900px) {
main {
flex-direction: column;
}
}
</style> </style>

View File

@ -1,39 +1,39 @@
<script> <script>
import Header from "$lib/header.svelte"; import Header from "$lib/header.svelte";
import Head from "$lib/head.svelte"; import Head from "$lib/head.svelte";
import Icon from "$lib/icon.svelte";
import { color } from "$lib/util.js"; import { color } from "$lib/util.js";
import { _ } from "svelte-i18n";
</script> </script>
<Head title="donate" desc="give me all of your life savings" /> <Head title="donate" desc="give me all of your life savings" />
<Header title="donate money!" picture="money" /> <Header picture="money" title={$_("donate.title")} />
<main> <main>
<span <span> </span>
>I spend a lot of time working on different projects and maintaining different services.</span <span>
> {$_("donate.info")}
<span {$_("donate.price")}
>I also spend a lot of money, but unlike time, you don't usually get much of it for free.</span </span>
>
<span
>I mostly pay for hosting and electricity. Which when added up costs around 550₺ per month, that
is Turkish Lira, equals to ~$15 at time of writing.</span
>
<br /> <br />
<br /> <br />
<span <span>
>So even a small donation would be highly appreciated and it would help me keep everything up {$_("donate.details")}
and running.</span </span>
>
<table> <table>
<thead> <thead>
<tr> <tr>
<th style="color: var(--{color()})">Platform</th> <th style="color: var(--{color()})">{$_("donate.table.platform")}</th>
<th style="color: var(--{color()})">Address/Link</th> <th style="color: var(--{color()})">{$_("donate.table.address")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr> <tr>
<td>Monero (XMR)</td> <td>
<Icon icon="nf-fa-monero" />
Monero (XMR)
</td>
<td> <td>
<code> <code>
46q7G7u7cmASvJm7AmrhmNg6ctS77mYMmDAy1QxpDn5w57xV3GUY5za4ZPZHAjqaXdfS5YRWm4AVj5UArLDA1retRkJp47F 46q7G7u7cmASvJm7AmrhmNg6ctS77mYMmDAy1QxpDn5w57xV3GUY5za4ZPZHAjqaXdfS5YRWm4AVj5UArLDA1retRkJp47F
@ -42,10 +42,9 @@
</tr> </tr>
</tbody> </tbody>
</table> </table>
<span <span>
>Also huge thanks to all of you who has donated so far, as I said, I highly appreciate it. Thank {$_("donate.thanks")}
you!</span </span>
>
</main> </main>
<style> <style>
@ -77,6 +76,7 @@
td, td,
th { th {
font-size: var(--size-4);
border: solid 1px var(--black-4); border: solid 1px var(--black-4);
padding: 16px; padding: 16px;
} }

View File

@ -1,7 +0,0 @@
import { services } from "$lib/api.js";
export async function load({ fetch }) {
return {
list: await services(fetch),
};
}

View File

@ -4,42 +4,42 @@
import Link from "$lib/link.svelte"; import Link from "$lib/link.svelte";
import Head from "$lib/head.svelte"; import Head from "$lib/head.svelte";
import { api_url } from "$lib/util.js"; import { _, locale } from "svelte-i18n";
import { locale } from "svelte-i18n"; import { api_url } from "$lib/api.js";
export let data; let { data } = $props();
let list = $state(data.services);
let list = data.list,
services = list;
let value = "";
function change(input) { function change(input) {
value = input.target.value.toLowerCase(); let value = input.target.value.toLowerCase();
services = []; list = [];
if (value === "") { if (value === "") {
services = list; list = data.services;
return; return;
} }
list.forEach((s) => { data.services.forEach((s) => {
if (s.name.toLowerCase().includes(value)) services.push(s); if (s.name.toLowerCase().includes(value)) list.push(s);
else if (s.desc[$locale.slice(0, 2)].toLowerCase().includes(value)) list.push(s);
}); });
} }
</script> </script>
<Head title="services" desc="my self-hosted services and projects" /> <Head title="services" desc="my self-hosted services and projects" />
<Header title="service status" picture="cool" /> <Header picture="cool" title={$_("services.title")} />
<main> <main>
<div class="title"> <div class="title">
<input on:input={change} type="text" placeholder="Search for a service" /> <input oninput={change} type="text" placeholder={$_("services.search")} />
<div> <div>
<Link icon="nf-fa-feed" link={api_url("/news/" + $locale.slice(0, 2))}>News and updates</Link> <Link icon="nf-fa-feed" link={api_url("/news/" + $locale.slice(0, 2))}
>{$_("services.feed")}</Link
>
</div> </div>
</div> </div>
<div class="services"> <div class="services">
{#each services as service} {#each list as service}
<Service {service} /> <Service {service} />
{/each} {/each}
</div> </div>

View File

@ -4,6 +4,22 @@
} }
} }
@keyframes cursor {
to {
border-color: transparent;
}
}
@keyframes typing {
from {
width: 0%;
}
to {
width: 100%;
}
}
@keyframes colorAnimation { @keyframes colorAnimation {
100%, 100%,
0% { 0% {

BIN
app/static/banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

@ -25,10 +25,11 @@
--size-4: 20px; --size-4: 20px;
--size-5: 24px; --size-5: 24px;
--size-6: 30px; --size-6: 30px;
--size-7: 50px;
--text-shadow: 0px 5px 20px rgba(90, 90, 90, 0.8); --text-shadow: 0px 5px 20px rgba(90, 90, 90, 0.8);
--box-shadow: rgba(20, 20, 20, 0.19) 0px 10px 20px, rgba(30, 30, 30, 0.23) 0px 6px 6px; --box-shadow: rgba(20, 20, 20, 0.19) 0px 10px 20px, rgba(30, 30, 30, 0.23) 0px 6px 6px;
--background: linear-gradient(rgba(11, 11, 11, 0.808), rgba(1, 1, 1, 0.96)), url("/banner.png");
--profile-size: 220px;
} }
* { * {
@ -36,6 +37,10 @@
margin: 0; margin: 0;
} }
html {
box-sizing: border-box;
}
body { body {
background: var(--black-1); background: var(--black-1);
font-family: "Ubuntu", sans-serif; font-family: "Ubuntu", sans-serif;

BIN
app/static/profile/sad.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB