init
This commit is contained in:
commit
d761a10bf7
102 changed files with 4761 additions and 0 deletions
22
hooks.client.ts
Normal file
22
hooks.client.ts
Normal 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
85
lib/badges.ts
Normal 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
113
lib/colors.js
Normal 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;
|
||||
|
49
lib/components/bio/UserPanel.svelte
Normal file
49
lib/components/bio/UserPanel.svelte
Normal 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>
|
12
lib/components/bio/elements/Banner.svelte
Normal file
12
lib/components/bio/elements/Banner.svelte
Normal 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>
|
40
lib/components/bio/elements/BannerContainer.svelte
Normal file
40
lib/components/bio/elements/BannerContainer.svelte
Normal 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>
|
40
lib/components/bio/elements/InteractivePanel.svelte
Normal file
40
lib/components/bio/elements/InteractivePanel.svelte
Normal 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>
|
36
lib/components/bio/elements/Panel.svelte
Normal file
36
lib/components/bio/elements/Panel.svelte
Normal 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>
|
13
lib/components/bio/elements/WidgetRenderContainer.svelte
Normal file
13
lib/components/bio/elements/WidgetRenderContainer.svelte
Normal 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}
|
52
lib/components/bio/profile/Avatar.svelte
Normal file
52
lib/components/bio/profile/Avatar.svelte
Normal 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>
|
25
lib/components/bio/profile/Badge.svelte
Normal file
25
lib/components/bio/profile/Badge.svelte
Normal 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>
|
11
lib/components/bio/profile/Badges.svelte
Normal file
11
lib/components/bio/profile/Badges.svelte
Normal 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>
|
7
lib/components/bio/profile/Description.svelte
Normal file
7
lib/components/bio/profile/Description.svelte
Normal 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}
|
22
lib/components/bio/profile/Extra.svelte
Normal file
22
lib/components/bio/profile/Extra.svelte
Normal 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}
|
12
lib/components/bio/profile/ExtraItem.svelte
Normal file
12
lib/components/bio/profile/ExtraItem.svelte
Normal 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>
|
11
lib/components/bio/profile/Footer.svelte
Normal file
11
lib/components/bio/profile/Footer.svelte
Normal 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}
|
72
lib/components/bio/profile/Name.svelte
Normal file
72
lib/components/bio/profile/Name.svelte
Normal 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>
|
31
lib/components/bio/profile/SocialLink.svelte
Normal file
31
lib/components/bio/profile/SocialLink.svelte
Normal 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>
|
15
lib/components/bio/profile/SocialText.svelte
Normal file
15
lib/components/bio/profile/SocialText.svelte
Normal 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>
|
92
lib/components/bio/profile/Socials.svelte
Normal file
92
lib/components/bio/profile/Socials.svelte
Normal 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>
|
19
lib/components/bio/widgets/Discord/Status.svelte
Normal file
19
lib/components/bio/widgets/Discord/Status.svelte
Normal 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}
|
40
lib/components/bio/widgets/DiscordUser.svelte
Normal file
40
lib/components/bio/widgets/DiscordUser.svelte
Normal 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>
|
15
lib/components/bio/widgets/ExternalSite.svelte
Normal file
15
lib/components/bio/widgets/ExternalSite.svelte
Normal 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>
|
17
lib/components/bio/widgets/InstagramPost.svelte
Normal file
17
lib/components/bio/widgets/InstagramPost.svelte
Normal 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>
|
23
lib/components/bio/widgets/Markdown.svelte
Normal file
23
lib/components/bio/widgets/Markdown.svelte
Normal 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>
|
17
lib/components/bio/widgets/PinterestPin.svelte
Normal file
17
lib/components/bio/widgets/PinterestPin.svelte
Normal 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>
|
17
lib/components/bio/widgets/SoundCloudTrack.svelte
Normal file
17
lib/components/bio/widgets/SoundCloudTrack.svelte
Normal 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>
|
5
lib/components/bio/widgets/SpotifyNowPlaying.svelte
Normal file
5
lib/components/bio/widgets/SpotifyNowPlaying.svelte
Normal file
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
export let data: WidgetSpotifyNowPlaying;
|
||||
export let handle: boolean = false;
|
||||
export let isPreview: boolean = false;
|
||||
</script>
|
13
lib/components/bio/widgets/Title.svelte
Normal file
13
lib/components/bio/widgets/Title.svelte
Normal 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>
|
17
lib/components/bio/widgets/TwitchLive.svelte
Normal file
17
lib/components/bio/widgets/TwitchLive.svelte
Normal 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>
|
17
lib/components/bio/widgets/TwitterPost.svelte
Normal file
17
lib/components/bio/widgets/TwitterPost.svelte
Normal 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>
|
17
lib/components/bio/widgets/YouTubeVideo.svelte
Normal file
17
lib/components/bio/widgets/YouTubeVideo.svelte
Normal 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>
|
15
lib/components/bio/widgets/types/GenericSite.svelte
Normal file
15
lib/components/bio/widgets/types/GenericSite.svelte
Normal 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>
|
15
lib/components/bio/widgets/types/Track.svelte
Normal file
15
lib/components/bio/widgets/types/Track.svelte
Normal 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>
|
23
lib/components/common/Tooltip.svelte
Normal file
23
lib/components/common/Tooltip.svelte
Normal 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>
|
15
lib/components/dashboard/editor/Handle.svelte
Normal file
15
lib/components/dashboard/editor/Handle.svelte
Normal 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>
|
1
lib/components/dashboard/editor/HandleDot.svelte
Normal file
1
lib/components/dashboard/editor/HandleDot.svelte
Normal file
|
@ -0,0 +1 @@
|
|||
<div class="bg-text-secondary w-1 h-1 rounded-full" />
|
3
lib/components/dashboard/editor/List.svelte
Normal file
3
lib/components/dashboard/editor/List.svelte
Normal file
|
@ -0,0 +1,3 @@
|
|||
<div class="flex flex-col gap-2">
|
||||
<slot />
|
||||
</div>
|
7
lib/components/dashboard/editor/ListElement.svelte
Normal file
7
lib/components/dashboard/editor/ListElement.svelte
Normal 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>
|
26
lib/components/dashboard/editor/Toggle.svelte
Normal file
26
lib/components/dashboard/editor/Toggle.svelte
Normal 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>
|
|
@ -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>
|
8
lib/components/dashboard/editor/types/DiscordUser.svelte
Normal file
8
lib/components/dashboard/editor/types/DiscordUser.svelte
Normal 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>
|
27
lib/components/dashboard/editor/types/ExternalSite.svelte
Normal file
27
lib/components/dashboard/editor/types/ExternalSite.svelte
Normal 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>
|
18
lib/components/dashboard/editor/types/InstagramPost.svelte
Normal file
18
lib/components/dashboard/editor/types/InstagramPost.svelte
Normal 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>
|
19
lib/components/dashboard/editor/types/Markdown.svelte
Normal file
19
lib/components/dashboard/editor/types/Markdown.svelte
Normal 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>
|
18
lib/components/dashboard/editor/types/PinterestPin.svelte
Normal file
18
lib/components/dashboard/editor/types/PinterestPin.svelte
Normal 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>
|
18
lib/components/dashboard/editor/types/SoundCloudTrack.svelte
Normal file
18
lib/components/dashboard/editor/types/SoundCloudTrack.svelte
Normal 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>
|
|
@ -0,0 +1,3 @@
|
|||
<script lang="ts">
|
||||
export let data: WidgetSpotifyNowPlaying;
|
||||
</script>
|
18
lib/components/dashboard/editor/types/Title.svelte
Normal file
18
lib/components/dashboard/editor/types/Title.svelte
Normal 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>
|
18
lib/components/dashboard/editor/types/TwitchLive.svelte
Normal file
18
lib/components/dashboard/editor/types/TwitchLive.svelte
Normal 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>
|
18
lib/components/dashboard/editor/types/TwitterPost.svelte
Normal file
18
lib/components/dashboard/editor/types/TwitterPost.svelte
Normal 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>
|
18
lib/components/dashboard/editor/types/YouTubeVideo.svelte
Normal file
18
lib/components/dashboard/editor/types/YouTubeVideo.svelte
Normal 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>
|
83
lib/components/dashboard/elements/Button.svelte
Normal file
83
lib/components/dashboard/elements/Button.svelte
Normal 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>
|
40
lib/components/dashboard/elements/InputGroup.svelte
Normal file
40
lib/components/dashboard/elements/InputGroup.svelte
Normal 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>
|
19
lib/components/dashboard/elements/MultilineTextInput.svelte
Normal file
19
lib/components/dashboard/elements/MultilineTextInput.svelte
Normal 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" />
|
17
lib/components/dashboard/elements/Spinner.svelte
Normal file
17
lib/components/dashboard/elements/Spinner.svelte
Normal 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>
|
23
lib/components/dashboard/elements/TextInput.svelte
Normal file
23
lib/components/dashboard/elements/TextInput.svelte
Normal 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" />
|
20
lib/components/dashboard/elements/Toggle.svelte
Normal file
20
lib/components/dashboard/elements/Toggle.svelte
Normal 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>
|
8
lib/components/dashboard/modal/Category.svelte
Normal file
8
lib/components/dashboard/modal/Category.svelte
Normal 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>
|
55
lib/components/dashboard/modal/Container.svelte
Normal file
55
lib/components/dashboard/modal/Container.svelte
Normal 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>
|
26
lib/components/dashboard/modal/Mount.svelte
Normal file
26
lib/components/dashboard/modal/Mount.svelte
Normal 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}
|
57
lib/components/dashboard/modal/NavbarModal.svelte
Normal file
57
lib/components/dashboard/modal/NavbarModal.svelte
Normal 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>
|
38
lib/components/dashboard/modal/Panel.svelte
Normal file
38
lib/components/dashboard/modal/Panel.svelte
Normal 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>
|
53
lib/components/dashboard/modal/types/Account.svelte
Normal file
53
lib/components/dashboard/modal/types/Account.svelte
Normal 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>
|
63
lib/components/dashboard/modal/types/ChangePassword.svelte
Normal file
63
lib/components/dashboard/modal/types/ChangePassword.svelte
Normal 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>
|
70
lib/components/dashboard/modal/types/Customization.svelte
Normal file
70
lib/components/dashboard/modal/types/Customization.svelte
Normal 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>
|
23
lib/components/dashboard/modal/types/DeleteWidget.svelte
Normal file
23
lib/components/dashboard/modal/types/DeleteWidget.svelte
Normal 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>
|
24
lib/components/dashboard/modal/types/DiscardChanges.svelte
Normal file
24
lib/components/dashboard/modal/types/DiscardChanges.svelte
Normal 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>
|
134
lib/components/dashboard/modal/types/EditWidget.svelte
Normal file
134
lib/components/dashboard/modal/types/EditWidget.svelte
Normal 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>
|
8
lib/components/dashboard/modal/types/Home.svelte
Normal file
8
lib/components/dashboard/modal/types/Home.svelte
Normal 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>
|
63
lib/components/dashboard/modal/types/NewWidget.svelte
Normal file
63
lib/components/dashboard/modal/types/NewWidget.svelte
Normal 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>
|
24
lib/components/dashboard/modal/types/NsfwWarning.svelte
Normal file
24
lib/components/dashboard/modal/types/NsfwWarning.svelte
Normal 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>
|
37
lib/components/dashboard/modal/types/User.svelte
Normal file
37
lib/components/dashboard/modal/types/User.svelte
Normal 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>
|
667
lib/components/dashboard/modal/types/UserPanel.svelte
Normal file
667
lib/components/dashboard/modal/types/UserPanel.svelte
Normal 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>
|
|
@ -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>
|
|
@ -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} />
|
9
lib/components/dashboard/statistics/Greeter.svelte
Normal file
9
lib/components/dashboard/statistics/Greeter.svelte
Normal 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>
|
121
lib/components/screens/Landing.svelte
Normal file
121
lib/components/screens/Landing.svelte
Normal 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>
|
||||
|
37
lib/components/screens/NoSuchUser.svelte
Normal file
37
lib/components/screens/NoSuchUser.svelte
Normal 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
16
lib/config/index.ts
Normal 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
112
lib/constraints.ts
Normal 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
25
lib/discord-statuses.ts
Normal 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
12
lib/models/mime.ts
Normal 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
113
lib/models/modal.ts
Normal 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
239
lib/models/socials.ts
Normal 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
229
lib/models/widget.ts
Normal 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
20
lib/socials.ts
Normal 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
452
lib/stores/editor.ts
Normal 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
39
lib/stores/modal.ts
Normal 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
10
lib/utils.ts
Normal 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
37
routes/+error.svelte
Normal 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
39
routes/+layout.svelte
Normal 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
51
routes/+page.svelte
Normal 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>
|
||||
|
111
routes/dashboard/+layout.svelte
Normal file
111
routes/dashboard/+layout.svelte
Normal 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>
|
||||
|
1
routes/dashboard/+layout.ts
Normal file
1
routes/dashboard/+layout.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const ssr = true;
|
42
routes/dashboard/login/+page.svelte
Normal file
42
routes/dashboard/login/+page.svelte
Normal 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>
|
||||
|
8
routes/dashboard/login/+page.ts
Normal file
8
routes/dashboard/login/+page.ts
Normal 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'),
|
||||
};
|
||||
};
|
30
routes/dashboard/login/recovery/+page.svelte
Normal file
30
routes/dashboard/login/recovery/+page.svelte
Normal 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}
|
||||
|
62
routes/dashboard/register/+page.svelte
Normal file
62
routes/dashboard/register/+page.svelte
Normal 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>
|
||||
|
8
routes/dashboard/register/+page.ts
Normal file
8
routes/dashboard/register/+page.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue