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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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