init
This commit is contained in:
commit
d761a10bf7
102 changed files with 4761 additions and 0 deletions
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>
|
Loading…
Add table
Add a link
Reference in a new issue