This commit is contained in:
skidoodle 2024-03-13 00:30:45 +01:00
commit d761a10bf7
102 changed files with 4761 additions and 0 deletions

22
hooks.client.ts Normal file
View file

@ -0,0 +1,22 @@
import { handleErrorWithSentry, Replay } from "@sentry/sveltekit";
import * as Sentry from '@sentry/sveltekit';
import config from "$lib/config";
Sentry.init({
dsn: config.sentryDsn,
tracesSampleRate: 1.0,
// This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production
replaysSessionSampleRate: 0.1,
// If the entire session is not sampled, use the below sample rate to sample
// sessions when an error occurs.
replaysOnErrorSampleRate: 1.0,
// If you don't want to use Session Replay, just remove the line below:
integrations: [new Replay()],
});
// If you have a custom error handler, pass it to `handleErrorWithSentry`
export const handleError = handleErrorWithSentry();

85
lib/badges.ts Normal file
View file

@ -0,0 +1,85 @@
export const badges: Badge[] = [
{
id: 'SYSTEM',
name: 'System',
icon: '/assets/badges/system.svg',
},
{
id: 'STAFF',
name: 'YourSitee Staff',
icon: '/assets/badges/staff.svg',
},
{
id: 'VERIFIED_PARTNER',
name: 'Partnered & Verified',
icon: '/assets/badges/verified.svg',
},
{
id: 'VERIFIED',
name: 'Verified Person',
icon: '/assets/badges/verified.svg',
},
{
id: 'VERIFIED_ORG',
name: 'Verified Organization',
icon: '/assets/badges/verified_org.svg',
},
{
id: 'VERIFIED_GOV',
name: 'Political Figure',
icon: '/assets/badges/verified_gov.svg',
},
{
id: 'VERIFIED_DEV',
name: 'Verified Developer',
icon: '/assets/badges/verified.svg',
},
{
id: 'VERIFIED_PREMIUM',
name: 'Verified by Premium',
icon: '/assets/badges/verified.svg',
},
{
id: 'DONATER',
name: 'Donator',
icon: '/assets/badges/donator.svg',
},
{
id: 'PREMIUM_BUSINESS',
name: 'Business',
icon: '/assets/badges/premium.svg',
},
{
id: 'PREMIUM_PRO',
name: 'Premium',
icon: '/assets/badges/premium.svg',
},
{
id: 'PREMIUM_BASIC',
name: 'Basic',
icon: '/assets/badges/premium.svg',
},
{
id: 'EARLY_SUPPORTER',
name: 'Early Supporter',
icon: '/assets/badges/early_supporter.svg',
},
{
id: 'BUG_HUNTER',
name: 'Bug Hunter',
icon: '/assets/badges/bug_hunter.svg',
},
{
id: 'SOFT_LAUNCH',
name: 'Soft-Launch',
icon: '/assets/badges/soft_launch.svg',
},
{
id: 'CANARY',
name: 'Beta Function',
icon: '/assets/badges/canary.svg',
},
];
export default badges;

113
lib/colors.js Normal file
View file

@ -0,0 +1,113 @@
// https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale
const light = {
step1: '#fbfdff', // Step 1: App background
step2: '#f9f9f9', // Step 2: Subtle background
step3: '#f1f1f1', // Step 3: UI element background
step4: '#ebebeb', // Step 4: Hovered UI element background
step5: '#e4e4e4', // Step 5: Active / Selected UI element background
step6: '#ddd', // Step 6: Subtle borders and separators
step7: '#d4d4d4', // Step 7: UI element border and focus rings
step8: '#bbb', // Step 8: Hovered UI element border
step9: '#8d8d8d', // Step 9: Solid backgrounds
step10: '#808080', // Step 10: Hovered solid backgrounds
step11: '#646464', // Step 11: Low-contrast text
step12: '#202020', // Step 12: High-contrast text
};
const dark = {
step1: '#090a0b', // Step 1: App background
step2: '#151515', // Step 2: Subtle background
step3: '#18181b', // Step 3: UI element background
step4: '#303030', // Step 4: Hovered UI element background
step5: '#373737', // Step 5: Active / Selected UI element background
step6: '#201c1c', // Step 6: Subtle borders and separators
step7: '#4a4a4a', // Step 7: UI element border and focus rings
step8: '#606060', // Step 8: Hovered UI element border
step9: '#6e6e6e', // Step 9: Solid backgrounds
step10: '#818181', // Step 10: Hovered solid backgrounds
step11: '#b1b1b1', // Step 11: Low-contrast text
step12: '#eee', // Step 12: High-contrast text
};
const lightColors = {
'light-app-bg': light.step1,
'light-subtle-bg': light.step2,
'light-ui-element-bg': light.step3,
'light-hover-ui-element-bg': light.step4,
'light-active-ui-element-bg': light.step5,
'light-subtle-separator': light.step6,
'light-ui-element-separator': light.step7,
'light-hover-ui-element-separator': light.step8,
'light-solid-bg': light.step9,
'light-hover-solid-bg': light.step10,
'light-low-contrast-txt': light.step11,
'light-high-contrast-txt': light.step12,
};
const darkColors = {
'dark-app-bg': dark.step1,
'dark-subtle-bg': dark.step2,
'dark-ui-element-bg': dark.step3,
'dark-hover-ui-element-bg': dark.step4,
'dark-active-ui-element-bg': dark.step5,
'dark-subtle-separator': dark.step6,
'dark-ui-element-separator': dark.step7,
'dark-hover-ui-element-separator': dark.step8,
'dark-solid-bg': dark.step9,
'dark-hover-solid-bg': dark.step10,
'dark-low-contrast-txt': dark.step11,
'dark-high-contrast-txt': dark.step12,
};
const theme = {
accent: '#4F81FE',
field: '#1E2023',
item: '#141719',
background: '#090A0B',
'widget-fill': '#0C0D0E99',
'widget-stroke': '#292C3180',
'widget-placeholder': '#292C3180',
'button-disabled': '#292A2A',
'button-secondary-active': '#1E212480',
'button-secondary-selected': '#343841',
'button-secondary-active-solid': '#2F3138',
'button-dark-transparent-fill': '#141719B3',
'button-dark-stroke-from': '#181B1E',
'button-dark-stroke-to': '#141719',
'button-dark-fill': '#141719',
'icon-inactive': '#677283',
'icon-active': '#fff',
'icon-disabled': '#2F343F',
'text-header': '#F4F6F6',
'text-primary': '#D1D6DB',
'text-secondary': '#768698',
'text-teritary': '#424C56',
'text-typeable': '#8B9299',
'text-clickable': '#637B96',
'text-disabled': '#404242',
'menu-border-from': '#6A748917',
'menu-border-to': '#12141957',
'menu-fill': '#161717CC',
'menu-transparent-fill': '#16171766',
};
const extras = {
'accent-hover': '#729AFE',
'accent-active': '#95B3FE',
};
const colors = {
...lightColors,
...darkColors,
...extras,
...theme,
};
export default colors;

View file

@ -0,0 +1,49 @@
<script lang="ts">
import Description from './profile/Description.svelte';
import Footer from './profile/Footer.svelte';
import Name from './profile/Name.svelte';
import Panel from './elements/Panel.svelte';
import Socials from './profile/Socials.svelte';
import Avatar from './profile/Avatar.svelte';
import Extra from './profile/Extra.svelte';
export let displayName: string;
export let username: string;
export let badges: BioSiteBadge[];
export let description: string | null;
export let socials: BioSiteSocials | undefined = undefined;
export let uid: number;
export let uniqueId: string;
export let views: number | null;
export let align: BioSiteAlign;
export let location: string | undefined = undefined;
export let school: string | undefined = undefined;
export let workplace: string | undefined = undefined;
export let preview: boolean = false;
export let placeholder: boolean = false;
export let loadTime: number | undefined = undefined;
export let previewUid: boolean | undefined = undefined;
export let storelessSocials: boolean = false;
$: uidLocal = uid === -1 ? undefined : uid;
</script>
<Panel {preview}>
<div class="flex flex-col gap-4">
<Avatar {uniqueId} {align} {loadTime} />
<Name identifier={displayName} secondaryIdentifier={`@${username}`} {badges} {align} {placeholder} />
<Description {description} />
<Extra {location} {school} {workplace} />
<Socials {socials} {storelessSocials} />
<slot />
<Footer uid={previewUid === undefined ? uid : previewUid === true ? uidLocal ?? -2 : undefined} {views} />
</div>
</Panel>

View file

@ -0,0 +1,12 @@
<script lang="ts">
export let banner: string | undefined = undefined;
</script>
<div class="w-full h-80 bg-center bg-cover md:rounded-[32px] shadow" style="--banner: url({banner ?? ''})" />
<style lang="postcss">
div {
@apply bg-accent;
background-image: var(--banner);
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import Banner from './Banner.svelte';
import { getBioBanner } from '$lib/config';
import { editorStore } from '$lib/stores/editor';
import { page } from '$app/stores';
export let loadTime: number | undefined = undefined;
export let bioId: number | undefined = undefined;
export let simple: boolean = false;
$: noBanner =
($page.data.bio?.hasBanner !== true && $editorStore.banner == null) ||
$editorStore.edits.edits.deleteBanner != null;
$: isEditor = $page.url.pathname.startsWith('/dashboard');
$: banner = noBanner
? undefined
: $editorStore.banner
? $editorStore.banner
: bioId
? getBioBanner(bioId, isEditor ? loadTime : undefined)
: undefined;
</script>
<div class="w-full grid">
{#if !simple}
<div class="w-full blur-[70px] opacity-40 dark:opacity-80 transition-opacity">
<Banner {banner} />
</div>
{/if}
<div class="w-full z-[1]">
<Banner {banner} />
</div>
</div>
<style lang="postcss">
div > div {
@apply col-start-1 row-start-1;
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
import { editorStore } from '$lib/stores/editor';
import { faExternalLink } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
import Panel from './Panel.svelte';
export let preview: boolean = false;
export let handle: boolean = false;
export let url: string | undefined = undefined;
export let title: string | undefined = undefined;
</script>
<a
class="panel cursor-pointer rounded-3xl hover:bg-dark-hover-ui-element-bg/50 dark:hover:bg-light-hover-ui-element-bg/50 active:bg-dark-active-ui-element-bg/50 dark:active:bg-light-active-ui-element-bg/50 transition-colors box-content"
href={!$editorStore.editMode ? url : undefined}
target="_blank">
<Panel {handle} {preview}>
<div class="flex gap-1" class:title>
<div class="flex-grow" class:min-w-0={!title}>
<slot />
</div>
<div class="contents text-text-clickable text-xs">
{#if title}
<p class="whitespace-nowrap leading-[.8] text-base font-normal">{title}</p>
{/if}
<Fa icon={faExternalLink} />
</div>
</div>
</Panel>
</a>
<style lang="postcss">
div.title {
@apply flex-col-reverse gap-0;
}
div.title > div:last-of-type {
@apply flex w-full justify-between items-center mb-5;
}
</style>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import Handle from '$lib/components/dashboard/editor/Handle.svelte';
import { editorStore } from '$lib/stores/editor';
export let preview: boolean = false;
export let handle: boolean = false;
export let fit: boolean = false;
export let full: boolean = false;
export let invisible: boolean = false;
$: hasHandle = handle ? $editorStore.editMode : false;
</script>
<div
class="panel flex-shrink-0 flex"
class:w-fit={fit}
class:w-full={full}
class:visible={!invisible}
class:widget-preview={preview}>
{#if hasHandle}
<Handle pad={true} />
{/if}
<div class="panel-content w-full h-fit min-w-0" class:!pl-0={hasHandle} class:-ml-6={hasHandle && invisible}>
<slot />
</div>
</div>
<style lang="postcss">
.panel.visible {
@apply bg-widget-fill rounded-3xl border border-widget-stroke backdrop-blur-xl transition-colors text-text-header transform-gpu;
}
.panel.visible .panel-content {
@apply p-6;
}
</style>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import Name from '../profile/Name.svelte';
export let hasPreview: boolean = true;
</script>
{#if hasPreview}
<slot />
{:else}
<div class="w-full">
<Name identifier={'Loading widget...'} secondaryIdentifier={'Loading...'} placeholder={true} />
</div>
{/if}

View file

@ -0,0 +1,52 @@
<script lang="ts">
import { page } from '$app/stores';
import { getUserAvatar } from '$lib/config';
import { editorStore } from '$lib/stores/editor';
export let loadTime: number | undefined = undefined;
export let uniqueId: string | undefined = undefined;
export let align: BioSiteAlign = 'LEFT';
export let small: boolean = false;
export let tiny: boolean = false;
export let veryTiny: boolean = false;
$: noAvatar =
($page.data.bio?.hasAvatar !== true && $editorStore.avatar == null) ||
$editorStore.edits.edits.deleteAvatar != null;
$: isEditor = $page.url.pathname.startsWith('/dashboard');
$: avatar = noAvatar
? undefined
: $editorStore.avatar
? $editorStore.avatar
: uniqueId
? getUserAvatar(uniqueId, isEditor ? loadTime : undefined)
: undefined;
</script>
<div
class="w-24 h-24 rounded-3xl avatar bg-center bg-cover"
class:self-center={align === 'CENTER'}
class:self-end={align === 'RIGHT'}
class:small
class:tiny
class:very-tiny={veryTiny}
style="--avatar: url({avatar ?? '/assets/default/avatar.svg'})" />
<style lang="postcss">
div.avatar {
background-image: var(--avatar);
}
div.small {
@apply w-16 h-16 rounded-2xl;
}
div.tiny {
@apply w-9 h-9 rounded-full;
}
div.very-tiny {
@apply w-6 h-6 rounded-full;
}
</style>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import badges from '$lib/badges';
import Tooltip from '$lib/components/common/Tooltip.svelte';
export let badge: BioSiteBadge;
const badgeObject = badges.find((x) => x.id == badge);
</script>
{#if badgeObject}
<a class="relative inline-block" href={badgeObject.link} aria-label={badgeObject.name}>
<div class="w-5 h-5 bg-cover bg-center peer" style="--icon: url({badgeObject.icon})" />
<div
class="relative opacity-0 peer-hover:opacity-100 -translate-y-5 peer-hover:-translate-y-8 transition-all"
aria-hidden="true">
<Tooltip>{badgeObject.name}</Tooltip>
</div>
</a>
{/if}
<style lang="postcss">
div {
background-image: var(--icon);
}
</style>

View file

@ -0,0 +1,11 @@
<script lang="ts">
import Badge from './Badge.svelte';
export let badges: BioSiteBadge[];
</script>
<div class="flex gap-0.5 items-center flex-wrap">
{#each badges as badge}
<Badge {badge} />
{/each}
</div>

View file

@ -0,0 +1,7 @@
<script lang="ts">
export let description: string | null | undefined = undefined;
</script>
{#if description}
<p class="text-text-primary break-words whitespace-pre-wrap">{description.trim()}</p>
{/if}

View file

@ -0,0 +1,22 @@
<script lang="ts">
import { faBriefcase, faGraduationCap, faLocationPin } from '@fortawesome/free-solid-svg-icons';
import ExtraItem from './ExtraItem.svelte';
export let location: string | undefined = undefined;
export let school: string | undefined = undefined;
export let workplace: string | undefined = undefined;
</script>
{#if location || school || workplace}
<div class="flex flex-wrap gap-x-3 gap-y-2">
{#if location}
<ExtraItem icon={faLocationPin} text={location.trim()} />
{/if}
{#if school}
<ExtraItem icon={faGraduationCap} text={school.trim()} />
{/if}
{#if workplace}
<ExtraItem icon={faBriefcase} text={workplace.trim()} />
{/if}
</div>
{/if}

View file

@ -0,0 +1,12 @@
<script lang="ts">
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
export let icon: IconDefinition;
export let text: string;
</script>
<div class="flex gap-1 text-text-secondary items-center">
<Fa {icon} />
<p class="break-words line-clamp-1">{text}</p>
</div>

View file

@ -0,0 +1,11 @@
<script lang="ts">
export let uid: number | null | undefined = undefined;
export let views: number | null | undefined = undefined;
</script>
{#if (uid || views) && uid !== -1}
<div class="border-t border-text-secondary pt-1 flex justify-between text-text-secondary text-xs">
{#if uid}<p>UID: {uid === -2 ? '??' : uid}</p>{/if}
{#if views}<p>VIEWS: {views}</p>{/if}
</div>
{/if}

View file

@ -0,0 +1,72 @@
<script lang="ts">
import BadgeContainer from './Badges.svelte';
export let identifier: string | null | undefined;
export let secondaryIdentifier: string | null | undefined;
export let badges: BioSiteBadge[] = [];
export let align: BioSiteAlign = 'LEFT';
export let placeholder: boolean = false;
</script>
<div class="flex flex-col" class:placeholder>
<div
class="primary-id text-xl font-semibold text-text-header flex gap-2 items-center"
class:justify-center={align === 'CENTER'}
class:justify-end={align === 'RIGHT'}>
<p class="break-words line-clamp-1">{(identifier ?? secondaryIdentifier ?? 'Unknown').trim()}</p>
{#if placeholder}
<div class="placeholder-container" />
{/if}
{#if badges.length > 0 && !placeholder}
<BadgeContainer {badges} />
{/if}
</div>
<div
class="secondary-id text-text-secondary"
class:text-center={align === 'CENTER'}
class:text-right={align === 'RIGHT'}>
<p class="break-words line-clamp-1">{(!identifier ? '' : secondaryIdentifier || '').trim()}</p>
{#if placeholder}
<div class="placeholder-container" />
{/if}
</div>
</div>
<style lang="postcss">
div.placeholder > * {
@apply w-fit px-2 overflow-hidden select-none rounded-md relative opacity-50;
}
div.placeholder > * {
@apply bg-text-teritary !text-text-teritary;
}
div.placeholder > div.primary-id {
@apply mb-[1px];
}
div.placeholder-container {
@apply absolute top-0 left-0 bottom-0 right-0;
}
div.placeholder div.placeholder-container {
animation: slide 1s infinite;
background: linear-gradient(
to right,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.8) 50%,
rgba(128, 186, 232, 0) 99%,
rgba(125, 185, 232, 0) 100%
);
}
@keyframes slide {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
</style>

View file

@ -0,0 +1,31 @@
<script lang="ts">
import type { Site } from '$lib/models/socials';
export let hoverInvert: boolean = false;
export let minimal: boolean = false;
export let site: Site | undefined;
export let social: BioSiteSocialLink;
</script>
{#if site}
<a
class="flex-grow"
style="--color: {site.color}"
href={`${site.type == 'EMAIL' ? 'mailto:' : site.baseUrl}${social.value}`}
target="_blank"
title={site.name}>
<div
class="w-full min-w-[95px] h-10 hover:opacity-80 transition-opacity rounded-lg flex justify-center items-center">
<img class="w-5 h-5" alt="Icon" src={site.icon} />
</div>
</a>
{/if}
<!-- -->
<style lang="postcss">
div {
background: var(--color);
}
</style>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import Button from '$lib/components/dashboard/elements/Button.svelte';
import { toast } from '@zerodevx/svelte-toast';
export let social: BioSiteSocialText;
</script>
<Button
title={social.value}
onClick={async () => {
await navigator.clipboard.writeText(social.value);
toast.push('Copied to clipboard.');
}}>
{social.title}
</Button>

View file

@ -0,0 +1,92 @@
<script lang="ts">
import { getLinkType } from '$lib/models/socials';
import { editorStore } from '$lib/stores/editor';
import SocialLink from './SocialLink.svelte';
import SocialText from './SocialText.svelte';
import { SocialLink as SocialLinkType } from '$lib/models/socials';
import { browser } from '$app/environment';
export let socials: BioSiteSocials | undefined = undefined;
export let storelessSocials: boolean = false;
$: socialLinks = (
[
...(socials?.links ?? [])
.map((x) => {
const remove = $editorStore.edits.edits.deleteSocialLinks.map((x) => x.id).findIndex((y) => y === x.id);
if (remove !== -1) return null;
return x;
})
.filter((x) => x),
...$editorStore.edits.edits.createSocialLinks
.filter(() => !storelessSocials)
.map((x) => ({ ...x, type: Object.values(SocialLinkType)[x.type] } as BioSiteSocialLink)),
].filter((x) => x) as BioSiteSocialLink[]
)
.map((x) => {
const index = $editorStore.edits.edits.indexSocialLinks.findIndex((y) => x.id == y.socialId);
if (index === -1) return x;
return { ...x, index: $editorStore.edits.edits.indexSocialLinks[index].index ?? -1 };
})
.sort((a, b) => a.index - b.index);
$: socialTexts = (
[
...(socials?.texts ?? [])
.map((x) => {
const remove = $editorStore.edits.edits.deleteSocialTexts.map((x) => x.id).findIndex((y) => y === x.id);
if (remove !== -1) return null;
return x;
})
.filter((x) => x),
...$editorStore.edits.edits.createSocialTexts.filter(() => !storelessSocials),
].filter((x) => x) as BioSiteSocialText[]
)
.map((x) => {
const index = $editorStore.edits.edits.indexSocialTexts.findIndex((y) => x.id == y.socialId);
if (index === -1) return x;
return { ...x, index: $editorStore.edits.edits.indexSocialTexts[index].index ?? -1 };
})
.sort((a, b) => a.index - b.index);
</script>
{#key socials}
{#if socials}
{#key socialLinks}
{#if socialLinks.length > 0}
<div class="flex flex-wrap gap-1.5" class:edit={$editorStore.editMode}>
{#each socialLinks as link}
<SocialLink
site={getLinkType(link.type)}
social={link}
hoverInvert={socials.invert}
minimal={socials.minimal} />
{/each}
</div>
{/if}
{/key}
{#if browser}
{#key socialTexts}
{#if socialTexts.length > 0}
<div class="flex flex-col gap-1.5" class:edit={$editorStore.editMode}>
{#each socialTexts as text}
<SocialText social={text} />
{/each}
</div>
{/if}
{/key}
{/if}
{/if}
{/key}
<style lang="postcss">
div {
@apply select-none;
}
div.edit {
@apply pointer-events-none;
}
</style>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import statuses from '$lib/discord-statuses';
export let status: string;
export let activity: string | null | undefined = undefined;
const statusObject = statuses.find((x) => x.id == status);
</script>
{#if statusObject}
<span
class="font-medium text-[var(--color)] before:inline-block before:content-[''] before:w-3 before:h-3 before:min-w-[0.75rem] before:min-h-[0.75rem] before:bg-[var(--color)] before:rounded-full before:mr-1"
style="--color: {statusObject.color}">
{statusObject.text}
</span>
{#if activity}
and
{/if}
{/if}

View file

@ -0,0 +1,40 @@
<script lang="ts">
import DiscordStatus from './Discord/Status.svelte';
import Panel from '../elements/Panel.svelte';
//@ts-ignore
export let data: WidgetDiscord;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<Panel preview={isPreview} {handle}>
<div class="flex flex-col gap-4">
<div class="flex justify-between items-center">
<div class="flex gap-4 items-center">
<div
class="avatar w-10 h-10 rounded-full bg-center bg-cover"
style={data.avatar ? `--avatar: url(${data.avatar})` : undefined} />
<p class="font-medium">@{data.username}</p>
</div>
<div>
<!-- TODO: Discord badges -->
</div>
</div>
{#if data.activity || data.status}
<p>
Currently <DiscordStatus status={data.status} activity={data.activity} />
{#if data.activity}
playing <span class="contents font-medium">{data.activity}</span>.
{/if}
</p>
{/if}
</div>
</Panel>
<style lang="postcss">
div.avatar {
@apply bg-accent;
background-image: var(--avatar);
}
</style>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import InteractivePanel from '../elements/InteractivePanel.svelte';
export let data: WidgetExternalSite;
export let nonInteractive: boolean = false;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<InteractivePanel preview={isPreview} url={!nonInteractive ? data.url : undefined} {handle}>
<div class="flex flex-col gap-1 justify-center">
<p class="text-lg line-clamp-1 break-all">{data.title || data.url}</p>
{#if data.title}<p class="text-secondary line-clamp-1 break-all">{data.url}</p>{/if}
</div>
</InteractivePanel>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import GenericSite from './types/GenericSite.svelte';
import InteractivePanel from '../elements/InteractivePanel.svelte';
import WidgetRenderContainer from '../elements/WidgetRenderContainer.svelte';
export let data: WidgetInstagramPost;
export let preview: WidgetPreviewInstagramPost | undefined = undefined;
export let nonInteractive: boolean = false;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<InteractivePanel preview={isPreview} title="Instagram Post" url={!nonInteractive ? data.url : undefined} {handle}>
<WidgetRenderContainer hasPreview={preview != null}>
<GenericSite thumbnail={preview?.thumbnail} title={preview?.author} description={preview?.description} />
</WidgetRenderContainer>
</InteractivePanel>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import Panel from '../elements/Panel.svelte';
export let data: WidgetMarkdown;
export let preview: WidgetRenderedMarkdown | undefined = undefined;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<Panel preview={isPreview} {handle}>
<div class="contents markdown">
{#key data}
{#key preview}
{#if preview}
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html preview.html}
{:else}
<p class="break-words whitespace-pre-line">{data.content.trim()}</p>
{/if}
{/key}
{/key}
</div>
</Panel>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import GenericSite from './types/GenericSite.svelte';
import InteractivePanel from '../elements/InteractivePanel.svelte';
import WidgetRenderContainer from '../elements/WidgetRenderContainer.svelte';
export let data: WidgetPinterestPin;
export let preview: WidgetPreviewPinterestPin | undefined = undefined;
export let nonInteractive: boolean = false;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<InteractivePanel preview={isPreview} title="Pinterest Pin" url={!nonInteractive ? data.url : undefined} {handle}>
<WidgetRenderContainer hasPreview={preview != null}>
<GenericSite thumbnail={preview?.thumbnail} title={preview?.title} description={preview?.description} />
</WidgetRenderContainer>
</InteractivePanel>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import Track from './types/Track.svelte';
import InteractivePanel from '../elements/InteractivePanel.svelte';
import WidgetRenderContainer from '../elements/WidgetRenderContainer.svelte';
export let data: WidgetSoundCloudTrack;
export let preview: WidgetPreviewSoundCloudTrack | undefined = undefined;
export let nonInteractive: boolean = false;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<InteractivePanel preview={isPreview} title="SoundCloud Track" url={!nonInteractive ? data.url : undefined} {handle}>
<WidgetRenderContainer hasPreview={preview != null}>
<Track thumbnail={preview?.thumbnail} title={preview?.title} artist={preview?.authorName} />
</WidgetRenderContainer>
</InteractivePanel>

View file

@ -0,0 +1,5 @@
<script lang="ts">
export let data: WidgetSpotifyNowPlaying;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import Panel from '../elements/Panel.svelte';
export let data: WidgetMarkdown;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<Panel preview={isPreview} invisible={true} {handle}>
<div class="px-6 py-3">
<p class="line-clamp-2 break-words font-semibold text-xl -mb-1 min-h-[28px]">{data.content}</p>
</div>
</Panel>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import GenericSite from './types/GenericSite.svelte';
import InteractivePanel from '../elements/InteractivePanel.svelte';
import WidgetRenderContainer from '../elements/WidgetRenderContainer.svelte';
export let data: WidgetTwitchLive;
export let preview: WidgetPreviewTwitchLive | undefined = undefined;
export let nonInteractive: boolean = false;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<InteractivePanel preview={isPreview} title="Twitch Live" url={!nonInteractive ? data.url : undefined} {handle}>
<WidgetRenderContainer hasPreview={preview != null}>
<GenericSite thumbnail={preview?.thumbnail} title={preview?.channel} description={preview?.description} />
</WidgetRenderContainer>
</InteractivePanel>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import GenericSite from './types/GenericSite.svelte';
import InteractivePanel from '../elements/InteractivePanel.svelte';
import WidgetRenderContainer from '../elements/WidgetRenderContainer.svelte';
export let data: WidgetTwitterPost;
export let preview: WidgetPreviewTwitterPost | undefined = undefined;
export let nonInteractive: boolean = false;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<InteractivePanel preview={isPreview} title="Twitter Post" url={!nonInteractive ? data.url : undefined} {handle}>
<WidgetRenderContainer hasPreview={preview != null}>
<GenericSite thumbnail={preview?.thumbnail} title={preview?.description} description={preview?.tag} />
</WidgetRenderContainer>
</InteractivePanel>

View file

@ -0,0 +1,17 @@
<script lang="ts">
import GenericSite from './types/GenericSite.svelte';
import InteractivePanel from '../elements/InteractivePanel.svelte';
import WidgetRenderContainer from '../elements/WidgetRenderContainer.svelte';
export let data: WidgetYouTubeVideo;
export let preview: WidgetPreviewYouTubeVideo | undefined = undefined;
export let nonInteractive: boolean = false;
export let handle: boolean = false;
export let isPreview: boolean = false;
</script>
<InteractivePanel preview={isPreview} title="YouTube Video" url={!nonInteractive ? data.url : undefined} {handle}>
<WidgetRenderContainer hasPreview={preview != null}>
<GenericSite thumbnail={preview?.thumbnail} title={preview?.title} description={preview?.description} />
</WidgetRenderContainer>
</InteractivePanel>

View file

@ -0,0 +1,15 @@
<script lang="ts">
export let thumbnail: string | undefined = undefined;
export let title: string | undefined = undefined;
export let description: string | undefined = undefined;
</script>
<div class="flex flex-col gap-4">
{#if thumbnail}
<img class="widget-thumbnail rounded-xl" src={thumbnail} alt="Thumbnail" />
{/if}
<div class="flex flex-col gap-3">
<p class="break-words text-text-header line-clamp-2 font-bold">{title}</p>
<p class="break-words text-text-primary line-clamp-4">{description}</p>
</div>
</div>

View file

@ -0,0 +1,15 @@
<script lang="ts">
export let thumbnail: string | undefined = undefined;
export let title: string | undefined = undefined;
export let artist: string | undefined = undefined;
</script>
<div class="flex gap-2 items-center">
{#if thumbnail}
<img class="widget-thumbnail h-14 rounded-xl" src={thumbnail} alt="Thumbnail" />
{/if}
<div class="flex flex-col justify-center w-full">
<p class="break-words text-text-header line-clamp-1 font-bold">{title}</p>
<p class="break-words text-text-primary line-clamp-1">{artist}</p>
</div>
</div>

View file

@ -0,0 +1,23 @@
<span class="absolute bottom-full left-1/2 -translate-x-1/2 pointer-events-none select-none">
<div class="relative z-[999] bg-item rounded-full">
<div class="relative text-white p-[10px] whitespace-nowrap text-base font-normal leading-[.8] rounded-full">
<slot />
</div>
</div>
</span>
<style lang="postcss">
span::after {
@apply content-[''] absolute top-full left-1/2 -ml-[5px] border-[5px] border-solid border-transparent border-t-item;
}
div {
@apply before:rounded-[32px] before:w-full before:h-full before:top-0 before:left-0 before:z-[-1] before:content-[''] before:absolute before:border before:border-transparent;
}
div::before {
background: linear-gradient(#25292c, theme('colors.item')) border-box;
mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
mask-composite: exclude;
}
</style>

View file

@ -0,0 +1,15 @@
<script lang="ts">
import HandleDot from './HandleDot.svelte';
export let pad: boolean = false;
</script>
<div class="handle flex gap-1 items-center cursor-grab z-[1]" class:p-6={pad}>
{#each Array(2) as _}
<div class="flex flex-col gap-1">
{#each Array(3) as _}
<HandleDot />
{/each}
</div>
{/each}
</div>

View file

@ -0,0 +1 @@
<div class="bg-text-secondary w-1 h-1 rounded-full" />

View file

@ -0,0 +1,3 @@
<div class="flex flex-col gap-2">
<slot />
</div>

View file

@ -0,0 +1,7 @@
<script lang="ts">
export let id: string | number | undefined = undefined;
</script>
<div class="list-element h-11 rounded-xl bg-field" data-id={id}>
<slot />
</div>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import Toggle from '../elements/Toggle.svelte';
const id = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString();
export let title: string | undefined = undefined;
export let subtitle: string | undefined = undefined;
export let name: string | undefined = undefined;
export let checked: boolean = false;
</script>
<div class="flex justify-between items-center">
<div class="flex flex-col gap-2">
<div class="flex justify-between">
{#if title}
<p class="text-text-header font-semibold text-2xl leading-[.8] whitespace-nowrap">
{title}
</p>
{/if}
</div>
{#if subtitle}
<p class="text-text-primary">{subtitle}</p>
{/if}
<slot />
</div>
<Toggle {id} {name} {checked} />
</div>

View file

@ -0,0 +1,32 @@
<script lang="ts">
import { clamp } from '$lib/utils';
import Spinner from '../elements/Spinner.svelte';
export let fetchingPreview: boolean | undefined = undefined;
export let scaleFactor: number = 1.0;
</script>
<div class="relative h-80 overflow-hidden -mt-4 -mx-4 p-4 border-b border-widget-stroke">
<div
class="relative widget-preview-container h-72 select-none flex justify-center items-center transition-opacity opacity-100"
class:opacity-60={fetchingPreview}>
<div
class="relative widget flex justify-center items-center pointer-events-none"
style="--scale: {clamp(scaleFactor, 0, 1) ?? 1.0}">
<slot />
</div>
</div>
<div
class="absolute top-0 left-0 bottom-0 right-0 flex justify-center items-center pointer-events-none transition-opacity opacity-0"
class:opacity-100={fetchingPreview}>
<div class="relative w-12 h-12 flex justify-center items-center bg-black/40 rounded-lg">
<Spinner />
</div>
</div>
</div>
<style lang="postcss">
.widget {
transform: scale(var(--scale, 1));
}
</style>

View file

@ -0,0 +1,8 @@
<script lang="ts">
import Button from '$lib/components/dashboard/elements/Button.svelte';
import { faDiscord } from '@fortawesome/free-brands-svg-icons';
</script>
<Button icon={faDiscord}>Link Discord</Button>
<Button style="red">Remove</Button>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetExternalSite;
$: widgetData = type[Widget.EXTERNAL_SITE].data as WidgetExternalSite;
</script>
<InputGroup title="Title" style="title">
<TextInput
name="title"
placeholder={widgetData.title}
value={data.title}
minlength={editorConstraints.externalSite.title.min}
maxlength={editorConstraints.externalSite.title.max} />
</InputGroup>
<InputGroup title="URL" style="title">
<TextInput
name="url"
placeholder={widgetData.url}
value={data.url}
minlength={editorConstraints.externalSite.url.min}
maxlength={editorConstraints.externalSite.url.max} />
</InputGroup>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetInstagramPost;
$: widgetData = type[Widget.INSTAGRAM_POST].data as WidgetInstagramPost;
</script>
<InputGroup title="URL" style="title">
<TextInput
name="url"
placeholder={widgetData.url}
value={data.url}
minlength={editorConstraints.instagramPost.url.min}
maxlength={editorConstraints.instagramPost.url.max} />
</InputGroup>

View file

@ -0,0 +1,19 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import MultilineTextInput from '$lib/components/dashboard/elements/MultilineTextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetMarkdown;
$: widgetData = type[Widget.MARKDOWN].data as WidgetMarkdown;
</script>
<InputGroup title="Markdown Text" style="title">
<MultilineTextInput
name="content"
rows={8}
value={data.content}
placeholder={widgetData.content}
minlength={editorConstraints.markdown.content.min}
maxlength={editorConstraints.markdown.content.max} />
</InputGroup>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetPinterestPin;
$: widgetData = type[Widget.PINTEREST_PIN].data as WidgetPinterestPin;
</script>
<InputGroup title="URL" style="title">
<TextInput
name="url"
placeholder={widgetData.url}
value={data.url}
minlength={editorConstraints.pinterestPin.url.min}
maxlength={editorConstraints.pinterestPin.url.max} />
</InputGroup>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetSoundCloudTrack;
$: widgetData = type[Widget.SOUNDCLOUD_TRACK].data as WidgetSoundCloudTrack;
</script>
<InputGroup title="URL" style="title">
<TextInput
name="url"
placeholder={widgetData.url}
value={data.url}
minlength={editorConstraints.soundCloudTrack.url.min}
maxlength={editorConstraints.soundCloudTrack.url.max} />
</InputGroup>

View file

@ -0,0 +1,3 @@
<script lang="ts">
export let data: WidgetSpotifyNowPlaying;
</script>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetTitle;
$: widgetData = type[Widget.TITLE].data as WidgetTitle;
</script>
<InputGroup title="Title" style="title">
<TextInput
name="content"
value={data.content}
placeholder={widgetData.content}
minlength={editorConstraints.markdown.content.min}
maxlength={editorConstraints.markdown.content.max} />
</InputGroup>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetTwitchLive;
$: widgetData = type[Widget.TWITCH_LIVE].data as WidgetTwitchLive;
</script>
<InputGroup title="URL" style="title">
<TextInput
name="url"
placeholder={widgetData.url}
value={data.url}
minlength={editorConstraints.twitchLive.url.min}
maxlength={editorConstraints.twitchLive.url.max} />
</InputGroup>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetTwitterPost;
$: widgetData = type[Widget.TWITTER_POST].data as WidgetTwitterPost;
</script>
<InputGroup title="URL" style="title">
<TextInput
name="url"
placeholder={widgetData.url}
value={data.url}
minlength={editorConstraints.twitterPost.url.min}
maxlength={editorConstraints.twitterPost.url.max} />
</InputGroup>

View file

@ -0,0 +1,18 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import Widget, { type } from '$lib/models/widget';
export let data: WidgetYouTubeVideo;
$: widgetData = type[Widget.YOUTUBE_VIDEO].data as WidgetYouTubeVideo;
</script>
<InputGroup title="URL" style="title">
<TextInput
name="url"
placeholder={widgetData.url}
value={data.url}
minlength={editorConstraints.youTubeVideo.url.min}
maxlength={editorConstraints.youTubeVideo.url.max} />
</InputGroup>

View file

@ -0,0 +1,83 @@
<script lang="ts">
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
import Spinner from './Spinner.svelte';
export let icon: IconDefinition | undefined = undefined;
export let style: 'default' | 'red' | 'white' | 'dark' | 'accent' = 'default';
export let onClick: void | (() => void) | ((e: MouseEvent) => void) | undefined = undefined;
export let tabindex: number | undefined = undefined;
export let disabled: boolean = false;
export let full: boolean = false;
export let xButton: boolean = false;
export let large: boolean = false;
export let small: boolean = false;
export let iconOnly: boolean = false;
export let loading: boolean = false;
export let title: string | undefined = undefined;
function click(e: MouseEvent) {
if (onClick) onClick(e);
}
$: isDisabled = loading || disabled;
</script>
<button
class="h-10 px-4 py-2 bg-button-secondary-active-solid hover:bg-button-secondary-selected active:bg-button-secondary-active-solid text-text-primary disabled:bg-button-disabled disabled:text-text-disabled transition-colors font-semibold rounded-lg justify-center items-center gap-2 inline-flex break-words line-clamp-1"
class:style-red={style === 'red'}
class:style-white={style === 'white'}
class:style-dark={style === 'dark'}
class:style-accent={style === 'accent'}
class:w-full={full}
class:x={xButton}
class:sm={small}
class:!h-12={large}
disabled={isDisabled}
class:cursor-not-allowed={isDisabled}
{title}
on:click={click}
{tabindex}>
{#if !loading}
{#if icon}
<Fa {icon} size="xs" />
{/if}
{#if xButton && !iconOnly}
<span class="contents"><slot /></span>
{:else}
<slot />
{/if}
{:else}
<Spinner small={small || xButton} />
{/if}
</button>
<style lang="postcss">
button.style-red {
@apply bg-red-500 hover:bg-red-400 active:bg-red-500;
}
button.style-white {
@apply bg-text-header hover:bg-text-primary active:bg-text-header text-item;
}
button.style-dark {
@apply backdrop-blur-3xl bg-button-dark-transparent-fill hover:bg-button-secondary-selected active:bg-button-dark-fill text-text-header;
}
button.style-accent {
@apply bg-accent hover:bg-accent-hover active:bg-accent-active text-text-header;
}
button.x {
@apply rounded-full font-normal h-fit min-h-[36.8px] p-3;
}
button.x.sm {
@apply py-2;
}
button.x > span {
@apply block whitespace-nowrap leading-[.8];
}
</style>

View file

@ -0,0 +1,40 @@
<script lang="ts">
export let title: string;
export let subtitle: string | undefined = undefined;
export let link: { url: string; text: string; tabindex?: number } | undefined = undefined;
export let required: boolean = false;
export let style: 'default' | 'title' | 'white' = 'default';
</script>
<div class="flex flex-col gap-2" class:style-title={style === 'title'} class:style-white={style === 'white'}>
<div class="flex justify-between">
<p class="title text-xs text-text-teritary">
{title || ''}{#if required}<span class="text-accent">*</span>{/if}
</p>
{#if link}
<a href={link.url} tabindex={link.tabindex}>{link.text}</a>
{/if}
</div>
{#if subtitle}
<p class="subtitle text-xs text-text-teritary">{subtitle}</p>
{/if}
<slot />
</div>
<style lang="postcss">
.style-title {
@apply gap-4;
}
.style-title p.title {
@apply text-text-header font-semibold text-2xl leading-[.8] whitespace-nowrap;
}
.style-title p.subtitle {
@apply text-base text-text-primary;
}
.style-white p.title {
@apply font-semibold text-text-header;
}
</style>

View file

@ -0,0 +1,19 @@
<script lang="ts">
export let name: string | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let required: boolean = false;
export let value: string | undefined = undefined;
export let rows: number | undefined = undefined;
export let minlength: number | undefined = undefined;
export let maxlength: number | undefined = undefined;
</script>
<textarea
{name}
{placeholder}
{rows}
{minlength}
{maxlength}
{required}
value={value ?? ''}
class="px-4 py-3 bg-field text-text-primary placeholder-text-typeable transition-all ring-0 outline outline-1 outline-transparent focus:outline-accent rounded-xl leading-tight" />

View file

@ -0,0 +1,17 @@
<script lang="ts">
export let small: boolean = false;
</script>
<div
class="inline-block h-6 w-6 animate-[spin_0.5s_linear_infinite] rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"
class:small
role="status">
<span class="!absolute !-m-px !h-px !w-px !overflow-hidden !whitespace-nowrap !border-0 !p-0 ![clip:rect(0,0,0,0)]"
>Loading...</span>
</div>
<style lang="postcss">
div.small {
@apply w-3 h-3 border-2;
}
</style>

View file

@ -0,0 +1,23 @@
<script lang="ts">
export let name: string | undefined = undefined;
export let type: 'text' | 'email' | 'password' = 'text';
export let placeholder: string | undefined = undefined;
export let required: boolean = false;
export let tabindex: number | undefined = undefined;
export let minlength: number | undefined = undefined;
export let maxlength: number | undefined = undefined;
export let hidden: boolean = false;
export let value: string | undefined = undefined;
</script>
<input
{name}
{type}
{placeholder}
{required}
{tabindex}
{minlength}
{maxlength}
value={value ?? ''}
class:hidden
class="px-4 py-3 bg-field text-text-primary placeholder-text-typeable transition-all ring-0 outline outline-1 outline-transparent focus:outline-accent rounded-xl leading-tight" />

View file

@ -0,0 +1,20 @@
<script lang="ts">
export let id: string | undefined = undefined;
export let name: string | undefined = undefined;
export let checked: boolean = false;
</script>
<label class="h-fit relative inline-flex items-center cursor-pointer">
<input
{id}
{name}
{checked}
type="checkbox"
value="true"
class="sr-only peer"
on:change={(e) => {
e.currentTarget.value = e.currentTarget.checked ? 'true' : 'false';
}} />
<div
class="w-11 h-6 bg-button-secondary-active-solid hover:bg-button-secondary-selected peer-checked:hover:bg-accent-hover peer-checked:bg-accent rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-icon-active after:rounded-full after:h-5 after:w-5 after:transition-all transition-colors" />
</label>

View file

@ -0,0 +1,8 @@
<script lang="ts">
export let title: string;
</script>
<div class="flex-grow flex flex-col gap-4 justify-center">
<div class="text-2xl leading-[.8] text-text-header font-semibold">{title}</div>
<slot />
</div>

View file

@ -0,0 +1,55 @@
<script lang="ts">
import { modalStore } from '$lib/stores/modal';
import { page } from '$app/stores';
import { afterNavigate } from '$app/navigation';
export let invisible: boolean = false;
export let align: BioSiteAlign = 'CENTER';
export let verticalEnd: boolean = false;
export let path: string | undefined = undefined;
export let navbarMount: boolean | undefined = undefined;
const validatePath = () => {
if (!path) return;
if ($page.url.pathname != path) {
$modalStore?.hideModal();
}
};
afterNavigate(validatePath);
</script>
<div
class="widget-container flex justify-center items-center"
class:not-visible={invisible}
class:navbar={navbarMount}
class:!justify-start={align === 'LEFT'}
class:!justify-end={align === 'RIGHT'}
class:!items-end={verticalEnd}
on:click|self={() => {
$modalStore?.hideModal();
}}
aria-hidden={navbarMount !== true ? 'true' : undefined}>
<div class:contents={align !== 'CENTER' && !navbarMount}>
<slot />
</div>
</div>
<style lang="postcss">
.widget-container.not-visible {
@apply pointer-events-none;
}
.widget-container:not(.not-visible):not(.navbar) {
@apply bg-black bg-opacity-[.65] backdrop-blur-md;
}
.widget-container.navbar,
.widget-container.navbar > div {
@apply w-full h-full;
}
.widget-container:not(.navbar) {
@apply fixed top-0 left-0 bottom-0 right-0 z-50 p-5;
}
</style>

View file

@ -0,0 +1,26 @@
<script lang="ts">
import Container from '$lib/components/dashboard/modal/Container.svelte';
import Panel from '$lib/components/dashboard/modal/Panel.svelte';
import type { ModalType } from '$lib/models/modal';
import { modalStore } from '$lib/stores/modal';
import { browser } from '$app/environment';
export let navbar: boolean = false;
export let modal: ModalType | null;
</script>
{#if browser && $modalStore && $modalStore.visible && (!navbar ? modal?.navbarMount !== true : modal?.navbarMount === true)}
<Container
invisible={modal?.container?.invisible}
verticalEnd={modal?.container?.verticalEnd}
align={modal?.container?.align}
path={modal?.path}
navbarMount={modal?.navbarMount}>
<Panel
transitionX={modal?.transition?.x ?? 0}
transitionY={modal?.transition?.y ?? 25}
navbarMount={modal?.navbarMount}>
<svelte:component this={modal?.component} {...$modalStore?.data} />
</Panel>
</Container>
{/if}

View file

@ -0,0 +1,57 @@
<script lang="ts">
import type Modal from '$lib/models/modal';
import { modalStore } from '$lib/stores/modal';
import { faChevronLeft, faSave, faTrashCan, faX } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
import Button from '../elements/Button.svelte';
export let title: string | undefined = undefined;
export let back: Modal | undefined = undefined;
export let saveFunc: void | (() => void) | undefined = undefined;
export let deleteFunc: void | (() => void) | undefined = undefined;
const dismissModal = () => {
$modalStore?.hideModal();
};
</script>
<div class="active-navbar-modal flex flex-col h-full -mx-4">
{#if title}
<div class="flex-shrink-0 h-16 flex justify-between items-center border-b border-widget-stroke">
{#if back}
<button on:click={() => (back ? $modalStore?.showModal(back) : null)}>
<Fa icon={faChevronLeft} />
</button>
{:else}
<div />
{/if}
<p class="w-full">{title}</p>
<button on:click={dismissModal}>
<Fa icon={faX} />
</button>
</div>
{/if}
<div class="flex-grow overflow-x-hidden overflow-y-auto px-4 pt-4">
<slot />
</div>
{#if saveFunc}
<div class="mx-4 mt-4 flex gap-2">
<Button full={true} icon={faSave} style="white" onClick={saveFunc}>Save</Button>
{#if deleteFunc}
<Button iconOnly={true} icon={faTrashCan} onClick={deleteFunc} />
{/if}
</div>
{/if}
</div>
<style lang="postcss">
div > div > button,
div > div > div {
@apply text-text-clickable hover:text-text-primary transition-colors active:text-text-secondary w-[60px] h-12;
}
div > div > button,
div > div > p {
@apply flex items-center justify-center;
}
</style>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { modalStore } from '$lib/stores/modal';
export let transitionX: number = 0;
export let transitionY: number = 25;
export let navbarMount: boolean | undefined = undefined;
const dismissModal = () => $modalStore?.hideModal();
</script>
<div
class="z-[51] flex flex-col gap-2 pointer-events-auto max-h-screen"
class:h-full={navbarMount === true}
aria-modal="true"
style="--x: {transitionX}px; --y: {transitionY}px;"
on:click|self={dismissModal}
on:keyup|self={dismissModal}>
<slot />
</div>
<style lang="postcss">
div {
animation-name: fly;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 0.2s;
}
@keyframes fly {
from {
opacity: 0;
transform: translateX(var(--x)) translateY(var(--y));
}
to {
}
}
</style>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import Button from '$lib/components/dashboard/elements/Button.svelte';
import Modal from '$lib/models/modal';
import { modalStore } from '$lib/stores/modal';
import NavbarModal from '../NavbarModal.svelte';
import Category from '../Category.svelte';
$: loading = {
logoutAll: false,
logout: false,
};
const redirectToUserPanelModal = () => {
const userPanel = document.querySelector('#user-panel');
if (!userPanel) return;
(userPanel as HTMLButtonElement).click();
};
</script>
<NavbarModal title="Account settings">
<div class="flex-col gap-8 flex">
<Category title="Username">
<p class="text-text-primary">
A unique identifier that shows up in your yoursit.ee URL. You can change this in the User Panel.
</p>
<Button large={true} onClick={redirectToUserPanelModal}>Take me there</Button>
</Category>
<Category title="Password & Security">
<Button large={true} onClick={() => $modalStore?.showModal(Modal.ChangePassword)}>Change Password</Button>
<Button
large={true}
loading={loading.logoutAll}
onClick={async () => {
if (confirm('Are you sure you want to terminate all sessions? You will need to login again.')) {
loading.logoutAll = true;
window.location.href = '/dashboard/logout/all';
$modalStore?.hideModal();
}
}}
style="red">Terminate all sessions</Button>
</Category>
<Category title="Manage account">
<Button
large={true}
loading={loading.logout}
onClick={async () => {
loading.logout = true;
window.location.href = '/dashboard/logout';
$modalStore?.hideModal();
}}>Log out</Button>
</Category>
</div>
</NavbarModal>

View file

@ -0,0 +1,63 @@
<script lang="ts">
import { applyAction, enhance } from '$app/forms';
import Button from '$lib/components/dashboard/elements/Button.svelte';
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import Modal from '$lib/models/modal';
import { modalStore } from '$lib/stores/modal';
import type { SubmitFunction } from '@sveltejs/kit';
import NavbarModal from '../NavbarModal.svelte';
import Category from '../Category.svelte';
import { editorConstraints } from '$lib/constraints';
const formAction: SubmitFunction = () => {
return async ({ result }) => {
await applyAction(result);
if (result.type === 'redirect') {
$modalStore?.showModal(Modal.Account);
alert('Password changed.');
}
};
};
</script>
<NavbarModal title="Change password">
<form class="flex-col gap-8 flex" method="POST" action="/dashboard/editor?/changePassword" use:enhance={formAction}>
<Category title="Change your password">
<div class="text-text-primary">After changing it, you'll need to use your new password to log in again.</div>
<InputGroup title="Enter your current password" required={true}>
<TextInput
name="old-password"
type="password"
placeholder="Current password"
minlength={editorConstraints.password.min}
maxlength={editorConstraints.password.max} />
</InputGroup>
<InputGroup title="Enter your new password" required={true}>
<TextInput
name="password"
placeholder="New password"
type="password"
required
minlength={editorConstraints.password.min}
maxlength={editorConstraints.password.max} />
<TextInput
name="password-confirm"
placeholder="New password again"
type="password"
required
minlength={editorConstraints.password.min}
maxlength={editorConstraints.password.max} />
</InputGroup>
</Category>
<div class="flex justify-between items-center flex-row-reverse">
<Button xButton={true} style="white">Change</Button>
<Button
xButton={true}
onClick={(e) => {
e.preventDefault();
$modalStore?.showModal(Modal.Account);
}}>Cancel</Button>
</div>
</form>
</NavbarModal>

View file

@ -0,0 +1,70 @@
<script lang="ts">
import BannerContainer from '$lib/components/bio/elements/BannerContainer.svelte';
import { onMount } from 'svelte';
import Button from '../../elements/Button.svelte';
import Category from '../Category.svelte';
import NavbarModal from '../NavbarModal.svelte';
import { editorStore } from '$lib/stores/editor';
import { modalStore } from '$lib/stores/modal';
import { AllowedMimes } from '$lib/models/mime';
import { page } from '$app/stores';
import { getBioBanner } from '$lib/config';
$: noBanner =
($page.data.bio?.hasBanner !== true && $editorStore.banner == null) ||
$editorStore.edits.edits.deleteBanner != null;
let file: HTMLInputElement | undefined;
onMount(() => {
if (!file) return;
file.onchange = () => {
if (!file || !file.files || file.files.length === 0) return;
if (file.files[0].size > 1024 * 1024 * 8) {
alert('File size must be less than 8MB.');
return;
}
$editorStore.setBanner(file.files[0]);
};
});
</script>
<input class="hidden" type="file" name="banner" accept={AllowedMimes.join(',')} bind:this={file} />
<NavbarModal title="Customization">
<Category title="Banner">
<p class="text-text-primary">Show an image in the background of your profile.</p>
<a
class="contents"
href={noBanner
? undefined
: $editorStore.banner
? $editorStore.banner
: $modalStore?.data.uniqueId
? getBioBanner($modalStore?.data.uniqueId, Date.now())
: undefined}
target="_blank">
<BannerContainer bioId={$modalStore?.data.bioId} simple={true} loadTime={$editorStore.loadTime} />
</a>
<div class="flex gap-2">
<Button
full={true}
large={true}
style="white"
onClick={(e) => {
e.preventDefault();
if (!file) return;
file.files = null;
file.click();
}}>Change</Button>
<Button
full={true}
large={true}
disabled={noBanner}
onClick={(e) => {
e.preventDefault();
$editorStore.deleteBanner();
}}>Remove</Button>
</div>
</Category>
</NavbarModal>

View file

@ -0,0 +1,23 @@
<script lang="ts">
import Button from '$lib/components/dashboard/elements/Button.svelte';
import { editorStore } from '$lib/stores/editor';
import { modalStore } from '$lib/stores/modal';
import ConfirmationDialog from './elements/ConfirmationDialog.svelte';
const deleteWidget = () => {
if (!$editorStore.widget) return;
$editorStore.deleteWidget($editorStore.widget.id);
$modalStore?.hideModal();
};
</script>
<ConfirmationDialog
title="Are you sure you want to delete this widget?"
subtitle="This action is still reversible by discarding your changes.">
<Button
full={true}
onClick={() => {
$modalStore?.hideModal();
}}>Cancel</Button>
<Button full={true} style="red" onClick={deleteWidget}>Delete</Button>
</ConfirmationDialog>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import Button from '$lib/components/dashboard/elements/Button.svelte';
import { editorStore } from '$lib/stores/editor';
import { modalStore } from '$lib/stores/modal';
import ConfirmationDialog from './elements/ConfirmationDialog.svelte';
const discardChanges = () => {
$editorStore?.reset();
$modalStore?.hideModal();
invalidateAll();
};
</script>
<ConfirmationDialog
title="Are you sure you want to discard the pending changes?"
subtitle="This action is irreversible.">
<Button
full={true}
onClick={() => {
$modalStore?.hideModal();
}}>Cancel</Button>
<Button full={true} style="red" onClick={discardChanges}>Discard</Button>
</ConfirmationDialog>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Modal from '$lib/models/modal';
import { type } from '$lib/models/widget';
import { editorStore } from '$lib/stores/editor';
import { modalStore } from '$lib/stores/modal';
import type { SubmitFunction } from '@sveltejs/kit';
import NavbarModal from '../NavbarModal.svelte';
import { browser } from '$app/environment';
import WidgetPreviewContainer from '../../editor/WidgetPreviewContainer.svelte';
import { onMount } from 'svelte';
$: widget = $editorStore.widget?.type ? type[$editorStore.widget.type] : null;
$: isNew = $modalStore?.data?.isNew ?? false;
let form: HTMLFormElement;
let submit: HTMLButtonElement;
let renderPoll: NodeJS.Timeout;
let currentWidget: BioSiteWidget['data'] | undefined;
let lastWidget: BioSiteWidget['data'] | undefined;
$: fetchingPreview = false;
$: scaleFactor = 1.0;
const calculatePreviewScale = () => {
const container = document.querySelector('.widget-preview-container');
const element = document.querySelector('.widget-preview');
if (!container || !element) return;
const containerHeight = container.clientHeight;
const elementHeight = element.clientHeight;
scaleFactor = containerHeight / elementHeight;
};
onMount(() => {
calculatePreviewScale();
});
$: calculatePreviewScale();
const renderPreview = async () => {
if (!$editorStore.widget || !form) {
clearTimeout(renderPoll);
return;
}
calculatePreviewScale();
const data: BioSiteWidget['data'] = Object.fromEntries(new FormData(form));
if (JSON.stringify(lastWidget || {}) === JSON.stringify(data)) return;
currentWidget = data;
if (browser && widget?.nonPreviewable !== true) {
fetchingPreview = true;
const preview = await fetch(`/api/renderPreview`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ type: $editorStore.widget.type, data } as RenderPreview),
})
.then((res) => res.json())
.finally(() => (fetchingPreview = false))
.catch(() => (fetchingPreview = false));
if (!preview.preview) return; // TODO: show toast
$editorStore.setPreview(preview.preview);
calculatePreviewScale();
}
lastWidget = data;
};
const setupRender = () => {
if (renderPoll) clearTimeout(renderPoll);
renderPoll = setInterval(renderPreview, 1000);
};
$: setupRender();
const saveWidget: SubmitFunction = ({ formData, cancel }) => {
if (!$editorStore.widget) {
cancel();
return;
}
const data: BioSiteWidget['data'] = Object.fromEntries(formData);
$editorStore.editWidget({
id: $editorStore.widget.id,
type: $editorStore.widget.type,
data,
visible: true,
index: 0,
align: -1,
});
$modalStore?.hideModal(true);
cancel();
};
const saveWidgetBtn = () => {
if (!submit) return;
submit.click();
};
const deleteWidget = () => {
if (!$editorStore.widget) return;
$modalStore?.showModal(Modal.DeleteWidget);
};
</script>
<NavbarModal
title={`${widget?.name ? widget.name + ' • ' : ''}${isNew ? 'New widget' : 'Edit widget'}`}
back={isNew ? Modal.NewWidget : undefined}
saveFunc={saveWidgetBtn}
deleteFunc={!isNew ? deleteWidget : undefined}>
{#if $editorStore.widget && widget}
<WidgetPreviewContainer {fetchingPreview} {scaleFactor}>
<div class="w-[350px]">
<svelte:component
this={widget.component}
data={{ ...$editorStore.widget.data, ...currentWidget }}
preview={$editorStore.widgetPreview}
isPreview={true} />
</div>
</WidgetPreviewContainer>
<form class="flex flex-col -mx-4 px-8 py-6 gap-8" method="POST" use:enhance={saveWidget} bind:this={form}>
<svelte:component this={widget.editor} data={$editorStore.widget.data} />
<button class="hidden" bind:this={submit} />
</form>
{:else}
Try again.
{/if}
</NavbarModal>

View file

@ -0,0 +1,8 @@
<script lang="ts">
import NavbarModal from '../NavbarModal.svelte';
import Greeter from '../../statistics/Greeter.svelte';
</script>
<NavbarModal title="Home">
<Greeter displayName={undefined} />
</NavbarModal>

View file

@ -0,0 +1,63 @@
<script lang="ts">
import { page } from '$app/stores';
import Modal from '$lib/models/modal';
import Widget, { type, type WidgetType } from '$lib/models/widget';
import { editorStore } from '$lib/stores/editor';
import { modalStore } from '$lib/stores/modal';
import { getRandomInt } from '$lib/utils';
import { toast } from '@zerodevx/svelte-toast';
import NavbarModal from '../NavbarModal.svelte';
import { editorConstraints } from '$lib/constraints';
$: widgetTypes = type
.map((x) => x)
.filter((x) => x.disabled !== true)
.sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));
const summonEditor = (widget: WidgetType) => {
if (
$editorStore.edits.edits.createWidgets.length +
$page.data.bio?.widgets.length -
$editorStore.edits.edits.deleteWidgets.length >=
editorConstraints.widgets.count
) {
toast.push("You've reached the maximum number of widgets.");
return;
}
$editorStore.setPreview();
$editorStore.setActive({
id: `new+${getRandomInt(100000, 999999)}`,
type: getType(widget.type),
index: 0,
visible: true,
data: widget.data,
align: -1,
});
$modalStore?.showModal(Modal.EditWidget, {
isNew: true,
});
};
const getType = (type: string) => {
const widgetType: Widget = Widget[type as keyof typeof Widget];
return widgetType;
};
</script>
<NavbarModal title="Add more widgets">
<div class="columns-1 gap-x-4 md:columns-2 -my-4">
{#each widgetTypes as widget}
<button
class="py-4 w-full h-fit text-left flex flex-col gap-2 transition-transform-and-opacity hover:scale-105 active:opacity-80 active:scale-[103.75%]"
on:click={() => {
summonEditor(widget);
}}>
<div class="w-full pointer-events-none">
<svelte:component this={widget.component} data={widget.data} preview={widget.preview} />
</div>
<p class="w-full text-center">{widget.name}</p>
</button>
{/each}
</div>
</NavbarModal>

View file

@ -0,0 +1,24 @@
<script lang="ts">
import Button from '$lib/components/dashboard/elements/Button.svelte';
import { modalStore } from '$lib/stores/modal';
import ConfirmationDialog from './elements/ConfirmationDialog.svelte';
export let link: string;
$: url = new URL(link);
</script>
<ConfirmationDialog
title="Sensitive Content ahead!"
subtitle="Hold up! You're going to {url.protocol}//{url.hostname} which is marked as an NSFW link.">
<Button
full={true}
onClick={() => {
$modalStore?.hideModal();
}}>Go Back</Button>
<Button
full={true}
onClick={() => {
window.location.href = link;
}}>Continue to site</Button>
</ConfirmationDialog>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import Name from '$lib/components/bio/profile/Name.svelte';
import { modalStore } from '$lib/stores/modal';
import { faArrowRightToBracket, faCog } from '@fortawesome/free-solid-svg-icons';
import Fa from 'svelte-fa';
export let displayName: string | undefined;
export let email: string | undefined;
export let badges: BioSiteBadge[];
</script>
<div
class="relative left-20 w-[300px] bg-dark-app-bg dark:bg-light-app-bg border border-dark-subtle-separator dark:border-light-subtle-separator rounded-lg flex flex-col justify-between">
<div class="flex flex-col gap-2 p-4">
<Name identifier={displayName} secondaryIdentifier={email} {badges} />
</div>
<div class="flex justify-between p-4 border-t border-dark-subtle-separator dark:border-light-subtle-separator">
<button class="text-primary">
<Fa icon={faCog} />
</button>
<button
class="text-red-500"
on:click={() => {
$modalStore?.hideModal();
goto('/dashboard/logout');
}}>
<Fa icon={faArrowRightToBracket} />
</button>
</div>
</div>
<style lang="postcss">
button {
@apply w-6 h-6 flex justify-center items-center rounded-lg p-1 transition-colors hover:bg-dark-hover-ui-element-bg dark:hover:bg-light-hover-ui-element-bg active:bg-dark-active-ui-element-bg dark:active:bg-light-active-ui-element-bg;
}
</style>

View file

@ -0,0 +1,667 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Button from '$lib/components/dashboard/elements/Button.svelte';
import { editorStore } from '$lib/stores/editor';
import { modalStore } from '$lib/stores/modal';
import type { SubmitFunction } from '@sveltejs/kit';
import NavbarModal from '../NavbarModal.svelte';
import WidgetPreviewContainer from '../../editor/WidgetPreviewContainer.svelte';
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import MultilineTextInput from '$lib/components/dashboard/elements/MultilineTextInput.svelte';
import UserPanel from '$lib/components/bio/UserPanel.svelte';
import { onMount } from 'svelte';
import Avatar from '$lib/components/bio/profile/Avatar.svelte';
import Category from '../Category.svelte';
import { AllowedMimes } from '$lib/models/mime';
import { page } from '$app/stores';
import { getUserAvatar } from '$lib/config';
import Toggle from '../../editor/Toggle.svelte';
import List from '../../editor/List.svelte';
import ListElement from '../../editor/ListElement.svelte';
import { SocialLink, getLinkType, linkType } from '$lib/models/socials';
import Fa from 'svelte-fa';
import { faX } from '@fortawesome/free-solid-svg-icons';
import type * as SortableType from 'sortablejs';
import { browser } from '$app/environment';
import Handle from '../../editor/Handle.svelte';
import { getRandomInt } from '$lib/utils';
import SocialsTextInput from './elements/SocialsTextInput.svelte';
import { editorConstraints } from '$lib/constraints';
import { toast } from '@zerodevx/svelte-toast';
let Sortable: any | undefined;
let linksContainer: HTMLElement | undefined;
let linksSortable: SortableType | undefined;
$: createSocialLinks = [] as BioSiteSocialLink[];
$: indexSocialLinks = [] as IndexSocialLink[];
$: deleteSocialLinks = [] as number[];
$: processedSocialLinks = [] as BioSiteSocialLink[];
const constructProcessedSocialLinks = () => {
processedSocialLinks = (
[...($page.data.bio?.socials.links ?? []), ...createSocialLinks]
.map((x) => {
const remove = deleteSocialLinks.findIndex((y) => y == x.id);
if (remove !== -1) return null;
return x;
})
.filter((x) => x) as BioSiteSocialLink[]
).map((x) => {
const index = indexSocialLinks.findIndex((y) => y.socialId == x.id);
if (index === -1) return x;
return { ...x, index: indexSocialLinks[index].index };
});
processedSocialLinks.sort((a, b) => a.index - b.index);
};
let textsContainer: HTMLElement | undefined;
let textsSortable: SortableType | undefined;
$: createSocialTexts = [] as BioSiteSocialText[];
$: indexSocialTexts = [] as IndexSocialText[];
$: deleteSocialTexts = [] as number[];
$: processedSocialTexts = [] as BioSiteSocialText[];
const constructProcessedSocialTexts = () => {
processedSocialTexts = (
[...($page.data.bio?.socials.texts ?? []), ...createSocialTexts]
.map((x) => {
const remove = deleteSocialTexts.findIndex((y) => y === x.id);
if (remove !== -1) return null;
return x;
})
.filter((x) => x) as BioSiteSocialText[]
).map((x) => {
const index = indexSocialTexts.findIndex((y) => y.socialId == x.id);
if (index === -1) return x;
return { ...x, index: indexSocialTexts[index].index };
});
processedSocialTexts.sort((a, b) => a.index - b.index);
};
$: previewSocials = {
...$page.data.bio?.socials,
links: processedSocialLinks,
texts: processedSocialTexts,
};
const createSortable = () => {
if (!Sortable || !linksContainer || !textsContainer) return;
const options: SortableType.Options = {
animation: 225,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
draggable: '.list-element',
handle: '.handle',
ghostClass: 'sort-ghost',
chosenClass: 'sort-chosen',
dragClass: 'sort-drag',
fallbackClass: 'sort-fallback',
direction: 'vertical',
swapThreshold: 0.8,
fallbackTolerance: 3,
touchStartThreshold: 5,
onEnd: () => {
const indexes = getIndexes(true);
let linkIndexes: IndexSocialLink[] = [];
let textIndexes: IndexSocialText[] = [];
if (indexes) {
linkIndexes = indexes.links;
textIndexes = indexes.texts;
}
previewSocials = {
...$page.data.bio?.socials,
links: processedSocialLinks.map((x) => {
const index = linkIndexes.findIndex((y) => y.socialId == x.id);
if (index === -1) return x;
return { ...x, index: linkIndexes[index].index };
}),
texts: processedSocialTexts.map((x) => {
const index = textIndexes.findIndex((y) => y.socialId == x.id);
if (index === -1) return x;
return { ...x, index: textIndexes[index].index };
}),
};
},
};
linksSortable = Sortable.create(linksContainer, { ...options, group: 'social-links' });
textsSortable = Sortable.create(textsContainer, { ...options, group: 'social-texts' });
};
const getIndexes = (viewOnly?: boolean) => {
if (!linksSortable || !textsSortable) return;
const sortables = {
links: linksSortable.toArray(),
texts: textsSortable.toArray(),
};
const mapLinks = (id: string, index: number) => {
if (!viewOnly && id.toString().startsWith('new+')) {
const createIndex = createSocialLinks.findIndex((edit) => edit.id === id);
if (createIndex !== -1) createSocialLinks[createIndex] = { ...createSocialLinks[createIndex], index };
return null;
}
return {
socialId: id,
index,
} as IndexSocialLink;
};
const mapTexts = (id: string, index: number) => {
if (!viewOnly && id.toString().startsWith('new+')) {
const createIndex = createSocialTexts.findIndex((edit) => edit.id === id);
if (createIndex !== -1) createSocialTexts[createIndex] = { ...createSocialTexts[createIndex], index };
return null;
}
return {
socialId: id,
index,
} as IndexSocialText;
};
const sorted = {
links: sortables.links.map(mapLinks).filter(Boolean) as IndexSocialLink[],
texts: sortables.texts.map(mapTexts).filter(Boolean) as IndexSocialText[],
};
return sorted;
};
$: noAvatar =
($page.data.bio?.hasAvatar !== true && $editorStore.avatar == null) ||
$editorStore.edits.edits.deleteAvatar != null;
let form: HTMLFormElement;
let submit: HTMLButtonElement;
let renderPoll: NodeJS.Timeout;
let previewData: any | undefined;
$: previewUid = $page.data.bio?.uid !== -1;
let file: HTMLInputElement | undefined;
$: scaleFactor = 1.0;
const calculatePreviewScale = () => {
const container = document.querySelector('.widget-preview-container');
const element = document.querySelector('.widget-preview');
if (!container || !element) return;
const containerHeight = container.clientHeight;
const elementHeight = element.clientHeight;
scaleFactor = containerHeight / elementHeight;
};
$: calculatePreviewScale();
onMount(async () => {
if (!browser) return;
calculatePreviewScale();
Sortable = (await import('sortablejs')).default;
createSortable();
createSocialLinks = [
...$editorStore.edits.edits.createSocialLinks.map(
(x) =>
({
...x,
type: Object.values(SocialLink)[x.type],
} as BioSiteSocialLink),
),
];
indexSocialLinks = [...$editorStore.edits.edits.indexSocialLinks];
deleteSocialLinks = [...$editorStore.edits.edits.deleteSocialLinks.map((x) => x.id)];
constructProcessedSocialLinks();
createSocialTexts = [...$editorStore.edits.edits.createSocialTexts] as BioSiteSocialText[];
indexSocialTexts = [...$editorStore.edits.edits.indexSocialTexts];
deleteSocialTexts = [...$editorStore.edits.edits.deleteSocialTexts.map((x) => x.id)];
constructProcessedSocialTexts();
if (!file) return;
file.onchange = () => {
if (!file || !file.files || file.files.length === 0) return;
if (file.files[0].size > 1024 * 1024 * 8) {
alert('File size must be less than 8MB.');
return;
}
$editorStore.setAvatar(file.files[0]);
};
});
const renderPreview = async () => {
if (!form) {
clearTimeout(renderPoll);
return;
}
calculatePreviewScale();
const data: any = Object.fromEntries(new FormData(form));
previewUid = data.publicUid === 'true' ? true : false;
previewData = data;
previewData.socials = previewSocials;
calculatePreviewScale();
};
const setupRender = () => {
if (renderPoll) clearTimeout(renderPoll);
renderPoll = setInterval(renderPreview, 500);
};
$: setupRender();
const saveWidget: SubmitFunction = ({ formData, cancel }) => {
const indexes = getIndexes();
if (indexes) {
indexSocialLinks = indexes.links;
indexSocialTexts = indexes.texts;
}
const data: any = Object.fromEntries(formData);
if ($modalStore?.data.displayName !== data.displayName) $editorStore.setDisplayName(data.displayName);
if ($modalStore?.data.username !== data.username) $editorStore.setUsername(data.username);
if ($modalStore?.data.description !== data.description) $editorStore.setDescription(data.description);
if ($modalStore?.data.location !== data.location) $editorStore.setLocation(data.location);
if ($modalStore?.data.school !== data.school) $editorStore.setSchool(data.school);
if ($modalStore?.data.workplace !== data.workplace) $editorStore.setWorkplace(data.workplace);
$editorStore.setSocials(
createSocialLinks,
createSocialTexts,
indexSocialLinks,
indexSocialTexts,
deleteSocialLinks,
deleteSocialTexts,
);
const publicUid = data.publicUid === 'true' ? true : false;
$editorStore.toggleUid(publicUid);
$modalStore?.hideModal();
cancel();
};
const saveWidgetBtn = () => {
if (!submit) return;
submit.click();
};
const changeSocialLink = (
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
},
link: BioSiteSocialLink,
) => {
e.currentTarget.setCustomValidity('Error during parse.');
let value = e.currentTarget.value.trim().replaceAll(' ', '');
if (value.length === 0) {
e.currentTarget.reportValidity();
return;
}
if (!value.includes(':')) value = `https://${value}`;
value = value.replace('www.', '');
const createSocialLink = createSocialLinks.find((x) => x.id === link.id);
if (!createSocialLink) {
e.currentTarget.reportValidity();
return;
}
const platformUrls = linkType.filter((x) => x.baseUrl).map((x) => new URL(x.baseUrl ?? ''));
const platformTypes = linkType.filter((x) => x.type).map((x) => x.type);
try {
new URL(value);
} catch (err) {
{
e.currentTarget.reportValidity();
return;
}
}
const valueUrl = new URL(value);
let platformIndex = platformUrls.findIndex((x) => x.hostname.replace('www.', '') === valueUrl.hostname);
if (platformIndex === -1)
platformIndex = platformTypes.findIndex(
(x) =>
x.toLowerCase().replaceAll('_', '') ==
valueUrl.protocol.toLowerCase().replaceAll('_', '').replaceAll(':', ''),
);
const type = linkType[platformIndex];
if (!type) {
e.currentTarget.setCustomValidity('Invalid platform.');
e.currentTarget.reportValidity();
return;
}
const path = valueUrl.pathname.split('/').filter((x) => x);
e.currentTarget.setCustomValidity('');
e.currentTarget.reportValidity();
createSocialLink.type = type.type as BioSiteSocialLinkType;
createSocialLink.value =
createSocialLink.type === 'ELEMENT'
? valueUrl.hash.replace('#/', '')
: createSocialLink.type === 'TIKTOK'
? path[path.length - 1].replace('@', '')
: path[path.length - 1];
constructProcessedSocialLinks();
};
const changeSocialTextTitle = (
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
},
text: BioSiteSocialText,
) => {
e.currentTarget.setCustomValidity('Error during parse.');
let value = e.currentTarget.value.trim();
if (value.length === 0) {
e.currentTarget.reportValidity();
return;
}
const createSocialText = createSocialTexts.find((x) => x.id === text.id);
if (!createSocialText) {
e.currentTarget.reportValidity();
return;
}
e.currentTarget.setCustomValidity('');
e.currentTarget.reportValidity();
createSocialText.title = value;
constructProcessedSocialTexts();
};
const changeSocialTextValue = (
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
},
text: BioSiteSocialText,
) => {
e.currentTarget.setCustomValidity('Error during parse.');
e.currentTarget.reportValidity();
let value = e.currentTarget.value.trim().replaceAll(' ', '');
if (value.length === 0) return;
const createSocialText = createSocialTexts.find((x) => x.id === text.id);
if (!createSocialText) return;
e.currentTarget.setCustomValidity('');
e.currentTarget.reportValidity();
createSocialText.value = value;
constructProcessedSocialTexts();
};
</script>
<input class="hidden" type="file" name="avatar" accept={AllowedMimes.join(',')} bind:this={file} />
<NavbarModal title="Profile Card • Edit widget" saveFunc={saveWidgetBtn}>
<WidgetPreviewContainer {scaleFactor}>
<div class="w-[350px]">
<UserPanel
{...$modalStore?.data}
{...previewData}
{previewUid}
loadTime={$editorStore.loadTime}
storelessSocials={true}
preview={true} />
</div>
</WidgetPreviewContainer>
<form class="flex flex-col -mx-4 px-8 py-6 gap-8" method="POST" use:enhance={saveWidget} bind:this={form}>
<button class="hidden" bind:this={submit} />
<div class="gap-2 flex w-full justify-between items-center">
<InputGroup title="Profile picture" style="title">
<div class="gap-2 flex">
<Button
xButton={true}
small={true}
style="white"
onClick={(e) => {
e.preventDefault();
if (!file) return;
file.files = null;
file.click();
}}>Change</Button>
<Button
xButton={true}
small={true}
disabled={noAvatar}
onClick={(e) => {
e.preventDefault();
$editorStore.deleteAvatar();
}}>Remove</Button>
</div>
</InputGroup>
<a
class="contents"
href={noAvatar
? undefined
: $editorStore.avatar
? $editorStore.avatar
: $modalStore?.data.uniqueId
? getUserAvatar($modalStore?.data.uniqueId, Date.now())
: undefined}
target="_blank">
<Avatar uniqueId={$modalStore?.data.uniqueId} small={true} loadTime={$editorStore.loadTime} />
</a>
</div>
<InputGroup title="Display Name" style="title">
<TextInput
name="displayName"
placeholder="Pick yourself a display name"
value={$modalStore?.data.displayName || ''}
minlength={editorConstraints.displayName.min}
maxlength={editorConstraints.displayName.max} />
</InputGroup>
<InputGroup title="Username" subtitle="This is unique to your profile." style="title">
<TextInput
name="username"
placeholder="Pick a cool username"
value={$modalStore?.data.username || ''}
minlength={editorConstraints.username.min}
maxlength={editorConstraints.username.max} />
</InputGroup>
<InputGroup title="Bio" subtitle="Introduce yourself in a few words." style="title">
<MultilineTextInput
name="description"
value={$modalStore?.data.description || ''}
minlength={editorConstraints.description.min}
maxlength={editorConstraints.description.max} />
</InputGroup>
<Category title="Extra Info">
<InputGroup title="Location" style="white">
<TextInput
name="location"
value={$modalStore?.data.location}
minlength={editorConstraints.location.min}
maxlength={editorConstraints.location.max} />
</InputGroup>
<InputGroup title="School" style="white">
<TextInput
name="school"
value={$modalStore?.data.school}
minlength={editorConstraints.school.min}
maxlength={editorConstraints.school.max} />
</InputGroup>
<InputGroup title="Workplace" style="white">
<TextInput
name="workplace"
value={$modalStore?.data.workplace}
minlength={editorConstraints.workplace.min}
maxlength={editorConstraints.workplace.max} />
</InputGroup>
</Category>
<InputGroup
title="Connections"
subtitle="Connect other sites to your profile here so that they are quickly accessible to everyone."
style="title">
<List>
<div class="contents" bind:this={linksContainer}>
{#key createSocialLinks}
{#key deleteSocialLinks}
{#each processedSocialLinks as link}
<ListElement id={link.id}>
<div class="h-full relative group">
{#if link.id.toString().startsWith('new+')}
<SocialsTextInput
id={`link-${link.id}`}
invisibleText={true}
value={`${getLinkType(link.type)?.baseUrl ?? ''}${link.value}`}
onChange={(e) => changeSocialLink(e, link)} />
{/if}
<div
class="w-full px-4 absolute top-0 left-0 bottom-0 right-0 flex justify-around items-center pointer-events-none gap-1">
<div class="w-full transparent flex items-center gap-2 overflow-hidden">
<div class="transparent contents pointer-events-auto">
<Handle />
</div>
{#if getLinkType(link.type)?.icon}
<img class="flex-shrink-0 w-4 h-4" alt="Icon" src={getLinkType(link.type)?.icon} />
{/if}
<p
class="whitespace-nowrap xs:block"
class:hidden={getLinkType(link.type)?.name}
class:text-text-secondary={!getLinkType(link.type)?.name}>
{getLinkType(link.type)?.name ?? 'Click here to edit Connection link...'}
</p>
</div>
<div class="w-fit max-w-[145px] flex flex-row-reverse items-center justify-start">
<button
class="delete w-full max-w-0 group-hover:max-w-[16px] focus:max-w-[16px] h-4 group-hover:opacity-100 focus:opacity-100 opacity-0 transition-all pointer-events-auto flex justify-center items-center text-text-clickable hover:text-text-primary peer"
on:click={(e) => {
e.preventDefault();
createSocialLinks = createSocialLinks.filter((x) => x.id !== link.id);
const id = link.id.toString();
if (!id.startsWith('new+')) deleteSocialLinks.push(Number.parseInt(id));
constructProcessedSocialLinks();
}}>
<Fa icon={faX} scale="0.75" />
</button>
<p
class="transparent w-full break-words line-clamp-1 group-hover:mr-2 peer-focus:mr-2 !transition-all">
{link.value}
</p>
</div>
</div>
</div>
</ListElement>
{/each}
{/key}
{/key}
</div>
<Button
onClick={(e) => {
e.preventDefault();
if (
$page.data.bio?.socials.links.length + createSocialLinks.length - deleteSocialLinks.length >=
editorConstraints.socialLinks.count
) {
toast.push("You've reached the maximum number of connections.");
return;
}
createSocialLinks.push({
id: `new+${getRandomInt(100000, 999999)}`,
index: linksSortable?.toArray().length ?? Number.MAX_SAFE_INTEGER,
type: 'UNKNOWN',
value: '',
});
constructProcessedSocialLinks();
}}>Add</Button>
</List>
</InputGroup>
<InputGroup title="Contact" subtitle="Add ways for people to contact you." style="title">
<List>
<div class="contents" bind:this={textsContainer}>
{#key createSocialTexts}
{#key deleteSocialTexts}
{#each processedSocialTexts as text}
<ListElement id={text.id}>
<div class="px-4 flex justify-between items-center gap-2 rounded-lg">
<div class="flex items-center gap-2 w-full">
<div class="contents">
<Handle />
</div>
<div class="w-full flex gap-1">
<SocialsTextInput
id={`text-${text.id}-title`}
placeholder={'Title'}
value={text.title ?? ''}
onChange={(e) => changeSocialTextTitle(e, text)} />
<SocialsTextInput
id={`text-${text.id}-value`}
placeholder={'Value'}
value={text.value ?? ''}
onChange={(e) => changeSocialTextValue(e, text)} />
</div>
</div>
<button
class="flex-shrink-0 w-4 h-4 transition-all flex justify-center items-center text-text-clickable hover:text-text-primary peer"
on:click={(e) => {
e.preventDefault();
createSocialTexts = createSocialTexts.filter((x) => x.id !== text.id);
const id = text.id.toString();
if (!id.startsWith('new+')) deleteSocialTexts.push(Number.parseInt(id));
constructProcessedSocialTexts();
}}>
<Fa icon={faX} scale="0.75" />
</button>
</div>
</ListElement>
{/each}
{/key}
{/key}
</div>
<Button
onClick={(e) => {
e.preventDefault();
if (
$page.data.bio?.socials.texts.length + createSocialTexts.length - deleteSocialTexts.length >=
editorConstraints.socialTexts.count
) {
toast.push("You've reached the maximum number of contacts.");
return;
}
createSocialTexts.push({
id: `new+${getRandomInt(100000, 999999)}`,
index: textsSortable?.toArray().length ?? Number.MAX_SAFE_INTEGER,
title: '',
value: '',
});
constructProcessedSocialTexts();
}}>Add</Button>
</List>
</InputGroup>
<Toggle
name="publicUid"
title="UID"
subtitle="Show your user ID on your profile card"
checked={$editorStore.edits.edits.toggleUid?.visible ?? $page.data.bio?.uid !== -1} />
</form>
</NavbarModal>
<style lang="postcss">
.transparent {
@apply transition-opacity;
}
.relative.group:has(input:focus) .transparent {
@apply opacity-0 !pointer-events-none;
}
.relative.group:has(input:focus) button.delete {
@apply max-w-[16px] opacity-100;
}
</style>

View file

@ -0,0 +1,16 @@
<script lang="ts">
export let title: string;
export let subtitle: string;
</script>
<div class="w-full max-w-[600px] p-8 bg-dark-app-bg dark:bg-light-app-bg rounded-[32px] flex flex-col gap-4">
<div class="flex justify-between">
<div class="flex items-center gap-4 text-primary">
<p class="text-2xl">{title}</p>
</div>
</div>
<p class="text-secondary">{subtitle}</p>
<div class="flex gap-4 justify-evenly flex-col sm:flex-row">
<slot />
</div>
</div>

View file

@ -0,0 +1,33 @@
<script lang="ts">
export let id: string | number;
export let invisibleText: boolean = false;
export let placeholder: string = '';
export let value: string | undefined = undefined;
export let onChange:
| void
| (() => void)
| ((
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
},
) => void)
| undefined = undefined;
const change = (
e: Event & {
currentTarget: EventTarget & HTMLInputElement;
},
) => {
if (onChange) onChange(e);
};
</script>
<input
name={id.toString()}
class="w-full h-full bg-transparent text-text-primary active:text-text-primary focus:text-text-primary rounded-xl peer px-4 py-3 transition-all ring-0 outline outline-1 outline-transparent focus:outline-accent leading-tight invalid:outline-red-600 placeholder-shown:outline-red-600"
class:text-transparent={invisibleText}
disabled={!id.toString().includes('new+')}
type="text"
{placeholder}
{value}
on:change={change} />

View file

@ -0,0 +1,9 @@
<script lang="ts">
export let displayName: string | undefined = undefined;
</script>
<p class="font-semibold text-xl text-text-header">
Hey there{#if displayName},
<span class="text-text-typeable">{displayName}</span>
{/if}!
</p>

View file

@ -0,0 +1,121 @@
<script lang="ts">
import UserPanel from '$lib/components/bio/UserPanel.svelte';
import socials from '$lib/socials';
import Tooltip from '../common/Tooltip.svelte';
export let bio: BioSite | undefined = undefined;
export let dim: boolean = false;
</script>
<img
class="bg blur-[10vh] brightness-110"
class:opacity-30={dim}
src="/assets/landing/background-blur.svg"
alt="Background blur" />
<img
class="bg blur-[3.25vh] brightness-125 z-10"
class:opacity-30={dim}
src="/assets/landing/background-logo.svg"
alt="Background logo" />
<div class="relative z-20 w-full h-full flex justify-between 2xl:gap-8 px-6 py-12 xs:p-12 sm:p-16 overflow-x-hidden">
<div class="w-full h-full flex flex-col justify-between gap-8">
<div class="flex flex-col gap-[50px] 2xl:gap-[60px] 3xl:gap-[120px]">
<header>
<a class="flex gap-2 items-center box-content" href="/">
<div class="w-8 h-8 bg-white img logo" title="YourSitee logo" />
<p class="text-white text-2xl font-medium">YourSit.ee</p>
<div class="w-6 h-[18px] bg-accent img alpha" title="Alpha" />
</a>
</header>
<main class="flex flex-col gap-10">
<slot />
</main>
</div>
<footer>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
{#each socials as social}
<a href={social.url} target="_blank" aria-label={social.name} class="group">
<div class="p-2 group">
<div
class="w-6 h-6 bg-text-clickable group-hover:bg-text-primary transition-colors img social"
style="--url: url({social.icon})" />
<div
class="relative opacity-0 group-hover:opacity-100 -translate-y-5 group-hover:-translate-y-8 transition-all"
aria-hidden="true">
<Tooltip>{social.name}</Tooltip>
</div>
</div>
</a>
{/each}
</div>
<div class="flex-col gap-2 inline-flex">
<div class="text-text-secondary">© YourSitee {new Date(Date.now()).getFullYear()}</div>
<div class="flex gap-4">
<a href="/privacy">Privacy Policy</a>
<a href="/terms">Terms of Service</a>
</div>
</div>
</div>
</footer>
</div>
{#if bio}
<div class="hidden lg:flex flex-shrink-0 items-end justify-end">
<div class="grid">
<div class="user-panel">
<UserPanel
displayName={bio.displayName}
username={bio.username}
badges={bio.badges}
description={bio.description}
socials={{ links: [], texts: [], minimal: false, invert: false }}
uid={bio.uid}
uniqueId={bio.uniqueId}
views={null}
align={bio.align}
location={bio.location ?? undefined} />
</div>
</div>
</div>
{/if}
</div>
<style lang="postcss">
.bg {
@apply w-screen h-screen fixed top-0 object-cover;
}
.img {
mask-position: center;
mask-size: cover;
mask-repeat: no-repeat;
}
.img.logo {
mask-image: url(/assets/brand/icon.svg);
}
.img.alpha {
mask-image: url(/assets/brand/alpha.svg);
}
.img.social {
mask-image: var(--url);
mask-size: contain;
}
.grid > div {
@apply col-start-1 row-start-1;
}
.user-panel {
@apply w-[350px] xl:w-[400px];
}
footer a {
@apply !text-text-clickable hover:!text-text-primary transition-colors;
}
</style>

View file

@ -0,0 +1,37 @@
<script lang="ts">
import Button from '../dashboard/elements/Button.svelte';
import UserPanel from '../bio/UserPanel.svelte';
export let username: string | undefined = undefined;
</script>
<div class="flex flex-col justify-between gap-8 h-full">
<div class="h-[72px] flex-col justify-center items-center gap-4 inline-flex text-center text-text-primary">
<p class="text-2xl font-semibold">404</p>
<p>Sorry, we couldn't find that page.</p>
</div>
<div class="w-[350px] sm:w-[400px]">
<UserPanel
displayName="Potentially You"
username={username ?? 'you'}
badges={['VERIFIED']}
description="We couldn't find this page. Maybe it doesn't exist, or it used to, but got deleted. Sorry!"
socials={{ links: [], texts: [], minimal: false, invert: false }}
uid={-1}
uniqueId={'0'}
views={null}
align={'LEFT'}>
<div class="flex flex-col gap-2">
{#if username}
<Button
large={true}
onClick={() => (window.location.href = `/dashboard/register?un=${username}`)}
style="white">Claim this page</Button>
{/if}
<Button large={true} onClick={() => (window.location.href = '/')}>Return</Button>
</div>
</UserPanel>
</div>
<div class="h-[72px]" />
</div>

16
lib/config/index.ts Normal file
View file

@ -0,0 +1,16 @@
import { env } from '$env/dynamic/public';
const config = {
cdnEndpoint: env.PUBLIC_CDN_ENDPOINT ?? 'https://cdn.example.org',
sentryDsn: env.PUBLIC_SENTRY_DSN ?? '',
};
export default config;
export const getUserAvatar = (uniqueId: string, time?: number) => {
return `${config.cdnEndpoint}/users/${uniqueId}/avatar.webp${time ? '?t=' + time.toString() : ''}`;
};
export const getBioBanner = (bioId: number, time?: number) => {
return `${config.cdnEndpoint}/bios/${bioId}/banner.webp${time ? '?t=' + time.toString() : ''}`;
};

112
lib/constraints.ts Normal file
View file

@ -0,0 +1,112 @@
const constraints = {
username: {
min: 2,
max: 32,
},
password: {
min: 8,
max: 64,
},
email: {
min: 3,
max: 64,
},
};
const widgetConstraints = {
externalSite: {
title: {
min: 0,
max: 32,
},
url: {
min: 0,
max: 250,
},
},
instagramPost: {
url: {
min: 0,
max: 250,
},
},
markdown: {
content: {
min: 0,
max: 1000,
},
},
pinterestPin: {
url: {
min: 0,
max: 250,
},
},
soundCloudTrack: {
url: {
min: 0,
max: 250,
},
},
title: {
content: {
min: 0,
max: 32,
},
},
twitchLive: {
url: {
min: 0,
max: 250,
},
},
twitterPost: {
url: {
min: 0,
max: 250,
},
},
youTubeVideo: {
url: {
min: 0,
max: 250,
},
},
};
export const editorConstraints = {
...constraints,
...widgetConstraints,
displayName: {
min: 0,
max: 30,
},
description: {
min: 0,
max: 250,
},
location: {
min: 0,
max: 20,
},
school: {
min: 0,
max: 30,
},
workplace: {
min: 0,
max: 30,
},
widgets: {
count: 15,
},
socialLinks: {
count: 12,
},
socialTexts: {
count: 5,
},
};
export default constraints;

25
lib/discord-statuses.ts Normal file
View file

@ -0,0 +1,25 @@
export const discordStatuses: DiscordStatus[] = [
{
id: 'online',
color: '#27A55B',
text: 'online',
},
{
id: 'away',
color: '#faa61a',
text: 'away',
},
{
id: 'dnd',
color: '#f04747',
text: 'do not disturb',
},
{
id: 'offline',
color: 'gray',
text: 'offline',
},
];
export default discordStatuses;

12
lib/models/mime.ts Normal file
View file

@ -0,0 +1,12 @@
enum Mime {
'image/webp',
'image/png',
'image/jpeg',
'image/gif',
}
export default Mime;
const Mimes = [...Object.keys(Mime)];
export const AllowedMimes = Mimes.slice(Mimes.length * -0.5);

113
lib/models/modal.ts Normal file
View file

@ -0,0 +1,113 @@
import Account from '$lib/components/dashboard/modal/types/Account.svelte';
import ChangePassword from '$lib/components/dashboard/modal/types/ChangePassword.svelte';
import Customization from '$lib/components/dashboard/modal/types/Customization.svelte';
import DeleteWidget from '$lib/components/dashboard/modal/types/DeleteWidget.svelte';
import DiscardChanges from '$lib/components/dashboard/modal/types/DiscardChanges.svelte';
import EditWidget from '$lib/components/dashboard/modal/types/EditWidget.svelte';
import Home from '$lib/components/dashboard/modal/types/Home.svelte';
import NewWidget from '$lib/components/dashboard/modal/types/NewWidget.svelte';
import NsfwWarning from '$lib/components/dashboard/modal/types/NsfwWarning.svelte';
import User from '$lib/components/dashboard/modal/types/User.svelte';
import UserPanel from '$lib/components/dashboard/modal/types/UserPanel.svelte';
enum Align {
left = 'LEFT',
center = 'CENTER',
right = 'RIGHT',
unknown = 'UNKNOWN',
}
enum Modal {
NsfwWarning,
User,
NewWidget,
EditWidget,
DeleteWidget,
DiscardChanges,
Account,
Home,
ChangePassword,
UserPanel,
Customization,
}
export interface ModalType {
component:
| typeof NsfwWarning
| typeof User
| typeof NewWidget
| typeof EditWidget
| typeof DeleteWidget
| typeof DiscardChanges
| typeof Account
| typeof Home
| typeof ChangePassword
| typeof UserPanel
| typeof Customization;
container?: {
invisible?: boolean;
verticalEnd?: boolean;
align?: Align;
};
transition?: {
x?: number;
y?: number;
};
path?: string;
navbarMount?: boolean;
}
export const type: ModalType[] = [
{ component: NsfwWarning },
{
component: User,
container: {
invisible: true,
verticalEnd: true,
align: Align.left,
},
transition: {
x: -25,
y: 0,
},
},
{
component: NewWidget,
path: '/dashboard/editor',
navbarMount: true,
},
{
component: EditWidget,
path: '/dashboard/editor',
navbarMount: true,
},
{ component: DeleteWidget, path: '/dashboard/editor' },
{ component: DiscardChanges, path: '/dashboard/editor' },
{
component: Account,
path: '/dashboard/editor',
navbarMount: true,
},
{
component: Home,
path: '/dashboard/editor',
navbarMount: true,
},
{
component: ChangePassword,
path: '/dashboard/editor',
navbarMount: true,
},
{
component: UserPanel,
path: '/dashboard/editor',
navbarMount: true,
},
{
component: Customization,
path: '/dashboard/editor',
navbarMount: true,
},
];
export default Modal;

239
lib/models/socials.ts Normal file
View file

@ -0,0 +1,239 @@
export enum SocialLink {
REDDIT,
GITHUB,
STEAM,
SOUND_CLOUD,
NAME_MC,
ELEMENT,
FACEBOOK,
INSTAGRAM,
KO_FI,
LINKED_IN,
PATREON,
PAYPAL,
SNAPCHAT,
SPOTIFY_USER,
SPOTIFY_ARTIST,
TIKTOK,
TWITCH,
KICK,
YOUTUBE,
TWITTER,
DISCORD_USER,
DISCORD_GUILD,
EMAIL,
TELEGRAM,
ROCKSTAR,
STEAM_PROFILE,
STEAM_USER,
OSU,
THREADS,
PINTEREST,
BLUESKY,
ROBLOX,
TUMBLR,
}
export enum SocialText {
EMAIL,
DISCORD,
}
export interface Site {
type: string;
name: string;
color: string;
baseUrl?: string;
icon: string;
}
export const linkType: Site[] = [
{
type: 'REDDIT',
name: 'Reddit',
baseUrl: 'https://reddit.com/u/',
// https://reddit.lingoapp.com/s/orqY1E/?v=16
color: '#FF4500',
icon: '/assets/socials/reddit.svg',
},
{
type: 'GITHUB',
name: 'GitHub',
baseUrl: 'https://github.com/',
// https://github.com/logos
color: '#24292F',
icon: '/assets/socials/github.svg',
},
{
type: 'STEAM',
name: 'Steam',
baseUrl: 'https://steamcommunity.com/id/',
// https://partner.steamgames.com/doc/marketing/branding
color: '#000',
icon: '/assets/socials/steam.svg',
},
{
type: 'SOUND_CLOUD',
name: 'SoundCloud',
baseUrl: 'https://soundcloud.com/',
// no official brand guidelines
color: '#FF661A',
icon: '/assets/socials/soundcloud.svg',
},
{
type: 'NAME_MC',
name: 'NameMC',
baseUrl: 'https://namemc.com/profile/',
// no official brand guidelines
color: '#000',
icon: '/assets/socials/namemc.svg',
},
{
type: 'ELEMENT',
name: 'Matrix',
baseUrl: 'https://matrix.to/#/',
// no official brand guidelines
color: '#0dbd8b',
icon: '/assets/socials/element.svg',
},
{
type: 'FACEBOOK',
name: 'Facebook',
baseUrl: 'https://www.facebook.com/',
// https://about.meta.com/brand/resources/facebookapp/logo/
color: '#1778f2',
icon: '/assets/socials/facebook.svg',
},
{
type: 'INSTAGRAM',
name: 'Instagram',
baseUrl: 'https://www.instagram.com/',
// https://about.meta.com/brand/resources/instagram/instagram-brand/
color: 'linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%)',
icon: '/assets/socials/instagram.svg',
},
{
type: 'KO_FI',
name: 'Ko-fi',
baseUrl: 'https://ko-fi.com/',
// https://more.ko-fi.com/brand-assets
color: '#FF5E5B',
icon: '/assets/socials/ko-fi.svg',
},
{
type: 'LINKED_IN',
name: 'LinkedIn',
baseUrl: 'https://linkedin.com/in/',
// https://brand.linkedin.com/en-us
color: '#0073B1',
icon: '/assets/socials/linkedin.svg',
},
{
type: 'PATREON',
name: 'Patreon',
baseUrl: 'https://patreon.com/',
// https://www.patreon.com/brand
color: '#FF424D',
icon: '/assets/socials/patreon.svg',
},
{
type: 'PAYPAL',
name: 'PayPal',
baseUrl: 'https://paypal.me/',
// https://newsroom.paypal-corp.com/media-resources
color: '#0070E0',
icon: '/assets/socials/paypal.svg',
},
{
type: 'SNAPCHAT',
name: 'Snapchat',
baseUrl: 'https://www.snapchat.com/add/',
// https://snap.com/en-US/brand-guidelines
color: '#FFFC00',
icon: '/assets/socials/snapchat.svg',
},
{
type: 'SPOTIFY_USER',
name: 'Spotify',
baseUrl: 'https://open.spotify.com/user/',
// https://developer.spotify.com/documentation/design
color: '#1db954',
icon: '/assets/socials/spotify.svg',
},
{
type: 'SPOTIFY_ARTIST',
name: 'Spotify Artist',
baseUrl: 'https://open.spotify.com/artist/',
// https://developer.spotify.com/documentation/design
color: '#1db954',
icon: '/assets/socials/spotify.svg',
},
{
type: 'TIKTOK',
name: 'TikTok',
baseUrl: 'https://www.tiktok.com/@',
// https://developers.tiktok.com/doc/getting-started-design-guidelines
color: '#000',
icon: '/assets/socials/tiktok.svg', // stolen from their homepage, since no official vector-based icon in their branding guidelines
},
{
type: 'TWITCH',
name: 'Twitch',
baseUrl: 'https://twitch.tv/',
// https://brand.twitch.tv
color: '#9146FF',
icon: '/assets/socials/twitch.svg',
},
{
type: 'KICK',
name: 'Kick',
baseUrl: 'https://kick.com/',
// https://drive.google.com/drive/folders/1k_eggskCbj20tGMyXmCdnXna78p5SAfR
color: '#000',
icon: '/assets/socials/kick.svg',
},
{
type: 'YOUTUBE',
name: 'YouTube',
baseUrl: 'https://www.youtube.com/@',
// https://www.youtube.com/howyoutubeworks/resources/brand-resources/
color: '#f00',
icon: '/assets/socials/youtube.svg',
},
{
type: 'TWITTER',
name: 'Twitter',
baseUrl: 'https://twitter.com/',
// https://web.archive.org/web/20230101101250/https://about.twitter.com/en/who-we-are/brand-toolkit
color: '#1D9BF0',
icon: '/assets/socials/twitter.svg',
},
{
type: 'DISCORD_USER',
name: 'Discord',
baseUrl: 'https://discord.com/users/',
// https://discord.com/branding
color: '#5865F2',
icon: '/assets/socials/discord.svg',
},
{
type: 'DISCORD_GUILD',
name: 'Discord Server',
baseUrl: 'https://discord.gg/',
// https://discord.com/branding
color: '#5865F2',
icon: '/assets/socials/discord.svg',
},
{
type: 'EMAIL',
name: 'Email',
baseUrl: '',
color: '#121212',
icon: '/assets/socials/email.svg',
},
];
export const getLinkType = (type: string) => {
return linkType.find((link) => link.type === type);
};

229
lib/models/widget.ts Normal file
View file

@ -0,0 +1,229 @@
import DiscordUser from '$lib/components/bio/widgets/DiscordUser.svelte';
import ExternalSite from '$lib/components/bio/widgets/ExternalSite.svelte';
import InstagramPost from '$lib/components/bio/widgets/InstagramPost.svelte';
import Markdown from '$lib/components/bio/widgets/Markdown.svelte';
import PinterestPin from '$lib/components/bio/widgets/PinterestPin.svelte';
import SoundCloudTrack from '$lib/components/bio/widgets/SoundCloudTrack.svelte';
import SpotifyNowPlaying from '$lib/components/bio/widgets/SpotifyNowPlaying.svelte';
import Title from '$lib/components/bio/widgets/Title.svelte';
import TwitchLive from '$lib/components/bio/widgets/TwitchLive.svelte';
import TwitterPost from '$lib/components/bio/widgets/TwitterPost.svelte';
import YouTubeVideo from '$lib/components/bio/widgets/YouTubeVideo.svelte';
import DiscordUserEditor from '$lib/components/dashboard/editor/types/DiscordUser.svelte';
import ExternalSiteEditor from '$lib/components/dashboard/editor/types/ExternalSite.svelte';
import InstagramPostEditor from '$lib/components/dashboard/editor/types/InstagramPost.svelte';
import MarkdownEditor from '$lib/components/dashboard/editor/types/Markdown.svelte';
import PinterestPinEditor from '$lib/components/dashboard/editor/types/PinterestPin.svelte';
import SoundCloudTrackEditor from '$lib/components/dashboard/editor/types/SoundCloudTrack.svelte';
import SpotifyNowPlayingEditor from '$lib/components/dashboard/editor/types/SpotifyNowPlaying.svelte';
import TitleEditor from '$lib/components/dashboard/editor/types/Title.svelte';
import TwitchLiveEditor from '$lib/components/dashboard/editor/types/TwitchLive.svelte';
import TwitterPostEditor from '$lib/components/dashboard/editor/types/TwitterPost.svelte';
import YouTubeVideoEditor from '$lib/components/dashboard/editor/types/YouTubeVideo.svelte';
import type { ComponentType } from 'svelte';
export enum Widget {
DISCORD,
EXTERNAL_SITE,
MARKDOWN,
SPOTIFY_NOW_PLAYING,
YOUTUBE_VIDEO,
TWITTER_POST,
SOUNDCLOUD_TRACK,
TWITCH_LIVE,
INSTAGRAM_POST,
PINTEREST_PIN,
TITLE,
}
export interface WidgetType {
type: string;
component: ComponentType;
name: string;
data:
| WidgetMarkdown
| WidgetExternalSite
| WidgetSpotifyNowPlaying
| WidgetYouTubeVideo
| WidgetTwitterPost
| WidgetSoundCloudTrack
| WidgetTwitchLive
| WidgetInstagramPost
| WidgetPinterestPin
| WidgetTitle;
preview?:
| WidgetRenderedMarkdown
| WidgetPreviewYouTubeVideo
| WidgetPreviewTwitterPost
| WidgetPreviewSoundCloudTrack
| WidgetPreviewTwitchLive
| WidgetPreviewInstagramPost
| WidgetPreviewPinterestPin;
priority?: number | null;
count?: number | null;
editor: ComponentType;
disabled?: boolean | null;
excludeFromCarousel?: boolean | null;
nonPreviewable?: boolean | null;
}
export const type: WidgetType[] = [
{
type: 'DISCORD',
component: DiscordUser,
name: 'Discord',
data: {},
editor: DiscordUserEditor,
disabled: true,
},
{
type: 'EXTERNAL_SITE',
component: ExternalSite,
name: 'Link',
data: {
title: 'YourSitee Homepage',
url: 'https://yoursit.ee',
} as WidgetExternalSite,
priority: 2,
editor: ExternalSiteEditor,
nonPreviewable: true,
},
{
type: 'MARKDOWN',
component: Markdown,
name: 'Text',
data: {
content: `## Welcome to YourSitee\nCreate the coolest bio page 🌍\n\nBe unique, stylish, special ✨`,
} as WidgetMarkdown,
preview: {
html: `<h2>Welcome to YourSitee</h2><p>Create the coolest bio page 🌍</p><p>Be unique, stylish, special ✨</p>`,
} as WidgetRenderedMarkdown,
priority: 0,
editor: MarkdownEditor,
},
{
type: 'SPOTIFY_NOW_PLAYING',
component: SpotifyNowPlaying,
name: 'Spotify - Now Playing',
data: {} as WidgetSpotifyNowPlaying,
editor: SpotifyNowPlayingEditor,
disabled: true,
},
{
type: 'YOUTUBE_VIDEO',
component: YouTubeVideo,
name: 'YouTube - Video',
data: {
url: 'https://www.youtube.com/watch?v=BHACKCNDMW8',
} as WidgetYouTubeVideo,
preview: {
title: '3 Hours of Amazing Nature Scenery & Relaxing Music for Stress Relief.',
description:
'Enjoy 3 hours of amazing nature scenery. This video features relaxing music that is ideal for sleep, study, meditation and yoga. ✿ Follow on Spotify https://...',
thumbnail: 'https://cdn.yoursit.ee/previews/youtube_video0.jpg',
channel: 'Cat Trumpet',
link: 'https://www.youtube.com/watch?v=BHACKCNDMW8',
} as WidgetPreviewYouTubeVideo,
editor: YouTubeVideoEditor,
},
{
type: 'TWITTER_POST',
component: TwitterPost,
name: 'Twitter - Tweet',
data: {
url: 'https://twitter.com/mifuyu_916/status/1734562899828552087',
} as WidgetTwitterPost,
preview: {
title: 'みふゆ(三冬) (@mifuyu_916)',
tag: '@mifuyu_916',
url: 'https://twitter.com/mifuyu_916/status/1734562899828552087',
description: '猫の枕',
thumbnail: 'https://cdn.yoursit.ee/previews/twitter_post0.jpg',
comments: 2,
retweets: 104,
likes: 1368,
} as WidgetPreviewTwitterPost,
editor: TwitterPostEditor,
},
{
type: 'SOUNDCLOUD_TRACK',
component: SoundCloudTrack,
name: 'SoundCloud - Track',
data: {
url: 'https://soundcloud.com/theneighbourhood/sweater-weather-1',
} as WidgetSoundCloudTrack,
preview: {
title: '"Sweater Weather"',
description: 'Listen to "Sweater Weather" by theneighbourhood #np on #SoundCloud',
thumbnail: 'https://cdn.yoursit.ee/previews/soundcloud_track0.jpg',
link: 'https://soundcloud.com/theneighbourhood/sweater-weather-1',
authorName: 'theneighbourhood',
authorUrl: 'https://soundcloud.com/theneighbourhood',
} as WidgetPreviewSoundCloudTrack,
editor: SoundCloudTrackEditor,
},
{
type: 'TWITCH_LIVE',
component: TwitchLive,
name: 'Twitch - Live',
data: {
url: 'https://www.twitch.tv/eslcs',
} as WidgetTwitchLive,
preview: {
channel: 'ESLCS - Twitch',
url: 'https://www.twitch.tv/eslcs',
description: 'ESL CLASSICS - NAVI IGS RUN: Natus Vincere vs. Astralis [Dust2] Map 2 - IEM Cologne 2021',
thumbnail: 'https://cdn.yoursit.ee/previews/twitch_live0.jpeg',
} as WidgetPreviewTwitchLive,
editor: TwitchLiveEditor,
},
{
type: 'INSTAGRAM_POST',
component: InstagramPost,
name: 'Instagram - Post',
data: {
url: 'https://www.instagram.com/p/C0oWjsurFfa/',
} as WidgetInstagramPost,
preview: {
title:
'Luluchannel on Instagram: "トリミングで綺麗に✨クリスマス🎄誰と🐶💕#ミニチュアシュナウザー #ミニシュナ #愛犬 #lulu #ルル#しゅなすたぐらむ #髭犬 #犬のいる暮らし #miniatureschnauzer #dog #フォロー歓迎 #いぬすた #dogstagram #schnauzer #dogsofinsta #dogsofinstagram"',
description:
'1,732 likes, 17 comments - schnauzer.luluchannel on December 9, 2023: "トリミングで綺麗に✨クリスマス🎄誰と🐶💕#ミニチュアシュナウザ..."',
thumbnail: 'https://cdn.yoursit.ee/previews/instagram_post0.jpg',
author: 'Luluchannel (@schnauzer.luluchannel) • Instagram reel',
link: 'https://www.instagram.com/reel/C0oWjsurFfa/',
} as WidgetPreviewInstagramPost,
editor: InstagramPostEditor,
},
{
type: 'PINTEREST_PIN',
component: PinterestPin,
name: 'Pinterest - Pin',
data: {
url: 'https://www.pinterest.com/pin/1087619378734380279/',
} as WidgetPinterestPin,
preview: {
title: 'Pin on Vegan Tofu and Tempeh Recipes',
description:
'Jan 16, 2021 - Sweet and Spicy Tempeh cooked with a kecap manis based sauce & Garlic Curry Noodles for a protein-packed vegan meal.',
thumbnail: 'https://cdn.yoursit.ee/previews/pinterest_pin0.jpg',
authorUrl: 'https://www.pinterest.com/corinaamira/',
link: 'https://www.pinterest.com/pin/1087619378734380279/',
} as WidgetPreviewPinterestPin,
editor: PinterestPinEditor,
},
{
type: 'TITLE',
component: Title,
name: 'Title',
data: {
content: `A title to organize YourSitee`,
} as WidgetTitle,
priority: 1,
editor: TitleEditor,
excludeFromCarousel: true,
nonPreviewable: true,
},
];
export default Widget;

20
lib/socials.ts Normal file
View file

@ -0,0 +1,20 @@
const socials = [
{
name: 'Discord',
icon: '/assets/socials/discord.svg',
url: 'https://discord.gg/8mXDcXbsqd',
},
{
name: 'Beta FAQ',
icon: '/assets/landing/soft-launch.svg',
url: 'https://l.yoursit.ee/beta-faq',
},
{
name: 'Apply for Beta',
icon: '/assets/landing/document.svg',
url: 'https://l.yoursit.ee/beta',
},
];
export default socials;

452
lib/stores/editor.ts Normal file
View file

@ -0,0 +1,452 @@
import { SocialLink } from '$lib/models/socials';
import type Sortable from 'sortablejs';
import { writable } from 'svelte/store';
interface EditorData {
loadTime: number;
editMode: boolean;
draggingWidget: boolean;
edits: EditorPublish;
previews: EmbedCacheFetched['widgets'];
widget?: BioSiteWidget | null;
widgetPreview?: WidgetPreview | null;
avatar?: string | null;
banner?: string | null;
sortables?: {
left?: Sortable;
right?: Sortable;
};
socialSortables?: {
links?: Sortable;
texts?: Sortable;
};
updatedSortable?: boolean;
}
interface EditorStore extends EditorData {
toggleEditMode: () => void;
setDraggingWidget: (draggingWidget: boolean) => void;
setActive: (widget?: BioSiteWidget) => void;
setPreview: (preview?: WidgetPreview) => void;
editWidget: (widget: BioSiteWidget) => void;
deleteWidget: (id: string | number) => void;
applyIndex: () => void;
setSortables: (left?: Sortable, right?: Sortable) => void;
setDisplayName: (displayName: string) => void;
setUsername: (username: string) => void;
setDescription: (description: string) => void;
setLocation: (location: string) => void;
setSchool: (school: string) => void;
setWorkplace: (workplace: string) => void;
setAvatar: (file: File) => void;
deleteAvatar: () => void;
setBanner: (file: File) => void;
deleteBanner: () => void;
setSocials: (
createSocialLinks: BioSiteSocialLink[],
createSocialTexts: BioSiteSocialText[],
indexSocialLinks: IndexSocialLink[],
indexSocialTexts: IndexSocialText[],
deleteSocialLinks: number[],
deleteSocialTexts: number[],
) => void;
toggleUid: (uid: boolean) => void;
reset: () => void;
}
export const generateResetState = (): EditorData => {
return {
loadTime: Date.now(),
editMode: false,
widget: null,
widgetPreview: null,
avatar: null,
banner: null,
edits: {
edits: {
createWidgets: [],
updateWidgets: [],
deleteWidgets: [],
indexWidgets: [],
alignWidgets: [],
imageWidgets: [],
updateDisplayName: null,
updateUsername: null,
updateDescription: null,
updateLocation: null,
updateSchool: null,
updateWorkplace: null,
deleteBanner: null,
deleteAvatar: null,
toggleUid: null,
createSocialLinks: [],
indexSocialLinks: [],
deleteSocialLinks: [],
createSocialTexts: [],
indexSocialTexts: [],
deleteSocialTexts: [],
},
avatar: null,
banner: null,
},
previews: {},
updatedSortable: false,
draggingWidget: false,
};
};
export const editorStore = writable({
...generateResetState(),
toggleEditMode: () => {
editorStore.update((store) => ({ ...store, editMode: !store.editMode }));
},
setDraggingWidget: (draggingWidget) => {
editorStore.update((store) => ({ ...store, draggingWidget, updatedSortable: true }));
},
setActive: (widget) => {
editorStore.update((store) => ({ ...store, widget }));
},
setPreview(preview) {
editorStore.update((store) => ({ ...store, widgetPreview: preview }));
},
editWidget: (widget) => {
editorStore.update((store) => {
const edits = store.edits;
if (widget.id.toString().startsWith('new+')) {
const index = edits.edits.createWidgets.findIndex((edit) => edit.widgetId === widget.id);
if (index !== -1) {
edits.edits.createWidgets[index] = {
...edits.edits.createWidgets[index],
...widget,
align: edits.edits.createWidgets[index].align,
};
} else {
edits.edits.createWidgets.push({
widgetId: widget.id,
type: widget.type,
visible: widget.visible,
align: 2,
index: -1,
data: widget.data,
});
}
} else {
const index = edits.edits.updateWidgets.findIndex((edit) => edit.widgetId === widget.id);
if (index !== -1) {
edits.edits.updateWidgets[index] = { ...edits.edits.updateWidgets[index], ...widget };
} else {
edits.edits.updateWidgets.push({
widgetId: widget.id,
align: 2,
data: widget.data,
});
}
}
if (!store.widgetPreview || !store.widget) return { ...store };
const previews = store.previews;
previews[store.widget.id] = store.widgetPreview;
return { ...store };
});
},
deleteWidget: (id) => {
editorStore.update((store) => {
const edits = store.edits;
if (!id.toString().startsWith('new+') && typeof id === 'number') edits.edits.deleteWidgets.push({ widgetId: id });
return {
...store,
edits: {
...edits,
edits: {
...edits.edits,
createWidgets: edits.edits.createWidgets.filter(function (edit) {
return edit.widgetId !== id;
}),
updateWidgets: edits.edits.updateWidgets.filter(function (edit) {
return edit.widgetId !== id;
}),
},
},
};
});
},
applyIndex: () => {
editorStore.update((store) => {
if (!store.sortables?.left || !store.sortables?.right) return store;
const sortables = {
left: store.sortables.left.toArray(),
right: store.sortables.right.toArray(),
};
const map = (id: string, index: number) => {
if (id.toString().startsWith('new+')) {
const edits = store.edits;
const createIndex = edits.edits.createWidgets.findIndex((edit) => edit.widgetId === id);
if (createIndex !== -1) {
const align = sortables.right.findIndex((sortableId) => sortableId === id) !== -1 ? 2 : 0;
edits.edits.createWidgets[createIndex] = { ...edits.edits.createWidgets[createIndex], index, align };
}
return null;
}
return {
widgetId: Number.parseInt(id),
index,
} as IndexWidget;
};
const sorted = {
left: sortables.left.map(map).filter(Boolean) as IndexWidget[],
right: sortables.right.map(map).filter(Boolean) as IndexWidget[],
};
const aligned = {
left: sorted.left.map((widget) => ({ ...widget, align: 0 } as AlignWidget)) as AlignWidget[],
right: sorted.right.map((widget) => ({ ...widget, align: 2 } as AlignWidget)) as AlignWidget[],
};
return {
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
indexWidgets: [...sorted.left, ...sorted.right],
alignWidgets: [...aligned.left, ...aligned.right],
},
} as EditorPublish,
};
});
},
setSortables: (left, right) => {
editorStore.update((store) => ({
...store,
sortables: { left: left ? left : store.sortables?.left, right: right ? right : store.sortables?.right },
}));
},
setDisplayName: (displayName) => {
editorStore.update((store) => ({
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
updateDisplayName: { newDisplayName: displayName },
},
},
}));
},
setUsername: (username) => {
editorStore.update((store) => ({
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
updateUsername: { newUsername: username },
},
},
}));
},
setDescription: (description) => {
editorStore.update((store) => ({
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
updateDescription: { newDescription: description },
},
},
}));
},
setLocation: (location) => {
editorStore.update((store) => ({
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
updateLocation: { newLocation: location },
},
},
}));
},
setSchool: (school) => {
editorStore.update((store) => ({
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
updateSchool: { newSchool: school },
},
},
}));
},
setWorkplace: (workplace) => {
editorStore.update((store) => ({
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
updateWorkplace: { newWorkplace: workplace },
},
},
}));
},
setAvatar: (file) => {
const url = URL.createObjectURL(file);
editorStore.update((store) => ({
...store,
avatar: url,
edits: {
...store.edits,
edits: {
...store.edits.edits,
deleteAvatar: null,
},
avatar: file,
},
}));
},
deleteAvatar: () => {
editorStore.update((store) => ({
...store,
avatar: null,
edits: {
...store.edits,
edits: {
...store.edits.edits,
deleteAvatar: {},
},
avatar: null,
},
}));
},
setBanner: (file) => {
const url = URL.createObjectURL(file);
editorStore.update((store) => ({
...store,
banner: url,
edits: {
...store.edits,
edits: {
...store.edits.edits,
deleteBanner: null,
},
banner: file,
},
}));
},
deleteBanner: () => {
editorStore.update((store) => ({
...store,
banner: null,
edits: {
...store.edits,
edits: {
...store.edits.edits,
deleteBanner: {},
},
banner: null,
},
}));
},
setSocials: (
createSocialLinks,
createSocialTexts,
indexSocialLinks,
indexSocialTexts,
deleteSocialLinks,
deleteSocialTexts,
) => {
editorStore.update((store) => {
return {
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
createSocialLinks: createSocialLinks
.filter((x) => x.type != 'UNKNOWN' && x.value)
.map(
(x) =>
({
id: x.id,
index: x.index,
type: Object.values(SocialLink).indexOf(x.type),
value: x.value,
} as CreateSocialLink),
),
createSocialTexts: createSocialTexts
.filter((x) => x.value)
.map((x) => ({ id: x.id, index: x.index, title: x.title, value: x.value })),
indexSocialLinks,
indexSocialTexts,
deleteSocialLinks: deleteSocialLinks.map((x) => ({ id: x })),
deleteSocialTexts: deleteSocialTexts.map((x) => ({ id: x })),
},
},
};
});
},
toggleUid: (visible) => {
editorStore.update((store) => ({
...store,
edits: {
...store.edits,
edits: {
...store.edits.edits,
toggleUid: { visible },
},
},
}));
},
reset: () => {
editorStore.update((store) => {
return {
...store,
...generateResetState(),
};
});
},
} as EditorStore);
export const hasEdits = (edits: EditorStore['edits']) => {
return (
[
...edits.edits.createWidgets,
...edits.edits.updateWidgets,
...edits.edits.deleteWidgets,
...edits.edits.indexWidgets,
...edits.edits.alignWidgets,
...edits.edits.imageWidgets,
...edits.edits.createSocialTexts,
...edits.edits.indexSocialTexts,
...edits.edits.deleteSocialTexts,
...edits.edits.createSocialLinks,
...edits.edits.indexSocialLinks,
...edits.edits.deleteSocialLinks,
].length !== 0 ||
edits.edits.deleteAvatar ||
edits.edits.deleteBanner ||
edits.edits.updateDescription ||
edits.edits.updateDisplayName ||
edits.edits.updateLocation ||
edits.edits.updateUsername ||
edits.edits.updateSchool ||
edits.edits.updateWorkplace ||
edits.edits.toggleUid ||
edits.avatar ||
edits.banner
);
};

39
lib/stores/modal.ts Normal file
View file

@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { browser } from '$app/environment';
import Modal from '$lib/models/modal';
import { toast } from '@zerodevx/svelte-toast';
import { writable } from 'svelte/store';
interface ModalStore {
visible: boolean;
type?: Modal | null;
data?: any;
warn?: boolean | null;
showModal: (type: Modal, data?: any) => void;
hideModal: (noConfirm?: boolean | null) => void;
disableWarn: () => void;
}
export const modalStore = browser
? writable({
visible: false,
type: null,
data: null,
warn: null,
showModal: (type: Modal, data?: any) => {
modalStore?.update((store) => ({ ...store, type, data, visible: true, warn: true }));
},
hideModal: (noConfirm) => {
modalStore?.update((store) => {
if (!noConfirm && store.type === Modal.EditWidget && store.warn) {
toast.push('Click again to dismiss.');
return { ...store, warn: false };
}
return { ...store, type: null, data: null, visible: false, warn: false };
});
},
disableWarn: () => {
modalStore?.update((store) => ({ ...store, warn: false }));
},
} as ModalStore)
: null;

10
lib/utils.ts Normal file
View file

@ -0,0 +1,10 @@
export const getRandomInt = (min: number, max: number) => {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
};
export const clamp = (value: number, min: number, max: number) => {
return Math.min(Math.max(value, min), max);
};

37
routes/+error.svelte Normal file
View file

@ -0,0 +1,37 @@
<script lang="ts">
import { browser } from '$app/environment';
import { page } from '$app/stores';
import Button from '$lib/components/dashboard/elements/Button.svelte';
import NoSuchUser from '$lib/components/screens/NoSuchUser.svelte';
$: loading = false;
$: (() => {
if (browser && window) window.onbeforeunload = null;
})();
</script>
<svelte:head>
<title>YourSitee</title>
</svelte:head>
<div class="flex justify-center items-center h-full p-8">
{#if $page.error?.code === 'NO_SUCH_USER'}
<NoSuchUser username={$page.url.pathname.split('/')[1]} />
{:else}
<div class="flex flex-col gap-4 w-[400px]">
<div>
<p class="text-[32px] font-bold">⚠️ An error occurred</p>
<p class="text-secondary">
{$page.data?.error ?? $page.error?.message ?? $page.error?.code ?? 'Unexpected error'}
</p>
</div>
<Button
{loading}
onClick={() => {
loading = true;
location.reload();
}}>Reload</Button>
</div>
{/if}
</div>

39
routes/+layout.svelte Normal file
View file

@ -0,0 +1,39 @@
<script lang="ts">
import 'inter-ui/inter.css';
import '../app.css';
import '../github-dark.css';
import '../github.css';
import Mount from '$lib/components/dashboard/modal/Mount.svelte';
import { modalStore } from '$lib/stores/modal';
import { browser } from '$app/environment';
import { type } from '$lib/models/modal';
import colors from '$lib/colors';
import { SvelteToast } from '@zerodevx/svelte-toast';
import type { SvelteToastOptions } from '@zerodevx/svelte-toast/stores.js';
const toastOptions: SvelteToastOptions = {
classes: ['toast-notification'],
};
$: modal = browser && $modalStore?.type != null ? type[$modalStore.type] : null;
const dismissModal = () => {
if ($modalStore?.visible && modal?.navbarMount !== true) $modalStore.hideModal();
};
</script>
<svelte:head>
<meta name="theme-color" content={colors.background} />
<meta property="og:type" content="website" />
<meta name="twitter:card" content="summary" />
</svelte:head>
<div class="w-full" on:mouseup={dismissModal} on:keyup={dismissModal}>
<slot />
</div>
<Mount {modal} />
<SvelteToast options={toastOptions} />

51
routes/+page.svelte Normal file
View file

@ -0,0 +1,51 @@
<script lang="ts">
import Avatar from '$lib/components/bio/profile/Avatar.svelte';
import Landing from '$lib/components/screens/Landing.svelte';
export let data;
$: title = 'The most advanced link-in-bio · YourSitee';
$: description =
'Feature-rich, customizable, modern, and user-friendly link-in-bio tool. Create your own sitee now — yoursit.ee!';
$: image = 'https://cdn.yoursit.ee/branding/logo_blue_background-sm.png';
</script>
<svelte:head>
<title>YourSitee</title>
<meta property="og:title" content={title} />
<meta name="twitter:title" content={title} />
<meta property="og:description" content={description} />
<meta name="twitter:description" content={description} />
<meta property="og:image" content={image} />
<meta name="twitter:image" content={image} />
</svelte:head>
<Landing bio={data.bio}>
<p class="text-[60px] 2xl:text-[100px] 3xl:text-[120px] font-medium leading-none max-w-[1330px]">
The most advanced <span class="whitespace-nowrap">link-in-bio</span>. Ever.
</p>
<p class="text-xl leading-[1.25] text-[#D1D6DB] max-w-[430px]">
YourSitee is a feature-rich, customizable, modern, and user-friendly link-in-bio tool.
</p>
<div class="flex gap-2 flex-col md:flex-row">
{#if data.session}
<a data-sveltekit-reload href="/dashboard" class="button primary flex gap-1"
><Avatar uniqueId={data.session.data.uniqueId} veryTiny={true} /> Continue to the Dashboard</a>
<a data-sveltekit-reload href="/dashboard/logout" class="button">Log out</a>
{:else}
<a data-sveltekit-reload href="/dashboard/register" class="button primary">Invited for Alpha? Sign up</a>
<a data-sveltekit-reload href="/dashboard/login" class="button">Log in</a>
{/if}
</div>
</Landing>
<style lang="postcss">
.button {
@apply bg-button-dark-transparent-fill hover:bg-button-dark-fill !text-text-primary font-medium rounded-2xl box-border px-4 py-2 w-fit h-fit;
}
.button.primary {
@apply bg-text-header hover:bg-text-primary !text-item;
}
</style>

View file

@ -0,0 +1,111 @@
<script lang="ts">
import { browser } from '$app/environment';
import { page } from '$app/stores';
import Navbar from '$lib/components/dashboard/Navbar.svelte';
import Showcase from '$lib/components/dashboard/auth/Showcase.svelte';
import ProfileContainerPlaceholder from '$lib/components/screens/ProfileContainerPlaceholder.svelte';
import { type } from '$lib/models/modal.js';
import { modalStore } from '$lib/stores/modal.js';
import Loading from '$lib/components/screens/Loading.svelte';
export let data;
$: auth =
$page.url.pathname.startsWith('/dashboard/login') ||
$page.url.pathname.startsWith('/dashboard/register') ||
$page.url.pathname.startsWith('/dashboard/logout');
$: modal = browser && $modalStore?.type != null ? type[$modalStore.type] : null;
$: isModal = $modalStore?.visible && modal?.navbarMount === true;
$: mouseDown = false;
const dismissModal = () => {
mouseDown = false;
if ($modalStore?.visible) $modalStore.hideModal();
};
</script>
{#if browser}
<div class="flex h-full">
{#if !auth}
<div class="w-full h-full">
<div class="h-full" on:mouseup={modal?.navbarMount ? undefined : dismissModal}>
<slot />
</div>
<div
class="navbar-container top-0 bottom-0 left-0 right-0 fixed z-40 flex flex-col justify-end items-center gap-4 p-6 overflow-hidden pointer-events-none select-none transition-all"
class:!p-0={$modalStore?.visible && modal?.navbarMount}
class:sm:!p-6={$modalStore?.visible && modal?.navbarMount}
class:!pointer-events-auto={$modalStore?.visible && modal?.navbarMount}
on:mousedown|self={() => (mouseDown = true)}
on:mouseup|self={() => (mouseDown ? dismissModal() : undefined)}
class:!gap-0={isModal}>
<Navbar sessionData={data.session?.data} />
</div>
</div>
{:else}
<div class="flex w-full h-full justify-center items-center">
<div
class="w-full h-full flex gap-6 xl:gap-[60px] 2xl:gap-[120px] pl-6 xl:pl-[60px] 2xl:pl-[120px] pr-6 items-center justify-center">
<div
class="flex-shrink-0 w-[400px] lg:max-h-screen px-1 py-6 overflow-x-hidden overflow-y-auto flex flex-col items-center gap-4 md:gap-6">
<a class="box-content p-4" href="/">
<div class="w-16 h-16 bg-white logo" title="YourSitee logo" />
</a>
<slot />
</div>
<div class="showcase w-full h-full max-w-[1920px] max-h-[1440px] hidden lg:block">
<Showcase />
</div>
</div>
</div>
{/if}
</div>
{:else}
{#if !auth}
<ProfileContainerPlaceholder uniqueId={data.session?.data.uniqueId} />
{:else}
<Loading />
{/if}
<noscript
><div class="absolute top-0 left-0 right-0 bottom-0 bg-dark-app-bg z-50 flex justify-center items-center">
<div class="p-8">
<p class="text-[32px] font-bold">⚠️ You don't have JavaScript enabled</p>
<p class="text-secondary">
Unfortunately, <b>JavaScript is required</b> for the dashboard. Enable it and try again.
</p>
</div>
</div></noscript>
{/if}
<style lang="postcss">
.navbar-container {
animation: pop-up 1.5s cubic-bezier(0.16, 1, 0.3, 1) 1;
transition: gap 0.2s linear;
}
@keyframes pop-up {
0% {
opacity: 0;
transform: translateY(3.25%);
}
50% {
opacity: 0;
transform: translateY(3.25%);
}
100% {
}
}
.logo {
mask-image: url(/assets/brand/icon.svg);
}
.showcase {
height: calc(100vh - 3rem);
}
</style>

View file

@ -0,0 +1 @@
export const ssr = true;

View file

@ -0,0 +1,42 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import Panel from '$lib/components/dashboard/auth/Panel.svelte';
import constraints from '$lib/constraints';
export let data;
</script>
<svelte:head>
<title>YourSitee</title>
</svelte:head>
<Panel
title="Welcome back! Log in to continue"
links={[
{ url: 'register', text: 'Register' },
// { url: 'login/recovery', text: 'Forgot password?' },
]}
button="Sign In">
<InputGroup title="Username/Email address" required={true}>
<TextInput
name="identifier"
required
placeholder="Provide your username or email"
tabindex={1}
minlength={1}
maxlength={64}
value={data.username ?? data.email ?? ''} />
</InputGroup>
<InputGroup title="Password" required={true}>
<TextInput
name="password"
required
placeholder="Password"
type="password"
tabindex={2}
minlength={constraints.password.min}
maxlength={constraints.password.max} />
</InputGroup>
</Panel>

View file

@ -0,0 +1,8 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
return {
username: url.searchParams.get('un'),
email: url.searchParams.get('e'),
};
};

View file

@ -0,0 +1,30 @@
<script lang="ts">
import Button from '$lib/components/dashboard/elements/Button.svelte';
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import Back from '$lib/components/dashboard/auth/Back.svelte';
import Panel from '$lib/components/dashboard/auth/Panel.svelte';
export let data;
</script>
<svelte:head>
<title>YourSitee</title>
</svelte:head>
<Back />
{#if !data.code}
<Panel
title="Forgot your password?"
subtitle="No problem at all! Let us send you an email with a recovery link to help you log into your account.">
<InputGroup title="Email address"><TextInput name="email" placeholder="you@example.com" required /></InputGroup>
<Button>Send recovery link</Button>
</Panel>
{:else}
<Panel title="Create a new password" action="?/new">
<InputGroup title="New password"><TextInput name="password" type="password" required /></InputGroup>
<InputGroup title="Confirm new password"><TextInput name="password-confirm" type="password" required /></InputGroup>
<Button>Set new password</Button>
</Panel>
{/if}

View file

@ -0,0 +1,62 @@
<script lang="ts">
import InputGroup from '$lib/components/dashboard/elements/InputGroup.svelte';
import TextInput from '$lib/components/dashboard/elements/TextInput.svelte';
import Panel from '$lib/components/dashboard/auth/Panel.svelte';
import Checkbox from '$lib/components/dashboard/elements/Checkbox.svelte';
import constraints from '$lib/constraints.js';
export let data;
</script>
<svelte:head>
<title>YourSitee</title>
</svelte:head>
<Panel
title="Join thousands of people already using YourSitee"
links={[
{ url: 'login', text: 'Login' },
// { url: 'login/recovery', text: 'Forgot password?' },
]}
button="Register">
<InputGroup title="Username" required={true}>
<TextInput
name="username"
required
placeholder="Pick a cool username"
minlength={constraints.username.min}
maxlength={constraints.username.max}
value={data.username ?? ''} />
</InputGroup>
<InputGroup title="Email address" required={true}>
<TextInput
name="email"
required
type="email"
placeholder="Provide your email"
minlength={constraints.email.min}
maxlength={constraints.email.max}
value={data.email ?? ''} />
</InputGroup>
<InputGroup title="Password" required={true}>
<TextInput
name="password"
required
placeholder="Password"
type="password"
minlength={constraints.password.min}
maxlength={constraints.password.max} />
<TextInput
name="password-confirm"
required
placeholder="Password again"
type="password"
minlength={constraints.password.min}
maxlength={constraints.password.max} />
</InputGroup>
<Checkbox name="accept" required>
By checking this box, I acknowledge that I have read and agree to abide by the <a href="/terms" target="_blank"
>Terms of Service</a>
and the <a href="/privacy" target="_blank">Privacy Policy</a>.</Checkbox>
</Panel>

View file

@ -0,0 +1,8 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
return {
username: url.searchParams.get('un'),
email: url.searchParams.get('e'),
};
};

Some files were not shown because too many files have changed in this diff Show more