Add classes: Actions + Divider + Illustration + InfoTable + Stat, Add utilities (needs docs)

This commit is contained in:
venashial
2022-04-02 16:17:58 -07:00
parent 6b54c342aa
commit 85b7147927
82 changed files with 1189 additions and 235 deletions

View File

@@ -0,0 +1,98 @@
<script lang="ts">
// TODO: Make square icon `md` more rounded
import { onMount } from 'svelte'
import { classCombine } from '$package/utils/classCombine'
/** Optional, as a default icon will be substituted if no image was specified */
export let src: string | undefined
export let size: 'xs' | 'sm' | 'md' | 'lg'
export let circle = false
export let floatUp = false
let className: string;
$: className = classCombine(['avatar', circle && 'avatar--circle', `avatar--size-${size}`, floatUp && 'avatar--float-up'])
let img
onMount(() => {
if (img && img.naturalWidth) {
const isPixelated = () => {
if (img.naturalWidth < 96 && img.naturalWidth > 0) {
img.style = 'image-rendering: pixelated;'
}
}
if (img.naturalWidth) {
isPixelated()
} else {
img.onload = isPixelated
}
}
})
</script>
{#if src}
<img {src} bind:this={img} class={className} alt=""/>
{:else}
<svg
class={className}
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 104 104"
>
<path fill="none" d="M0 0h103.4v103.4H0z"/>
<path
fill="none"
stroke="#9a9a9a"
stroke-width="5"
d="M51.7 92.5V51.7L16.4 31.3l35.3 20.4L87 31.3 51.7 11 16.4 31.3v40.8l35.3 20.4L87 72V31.3L51.7 11"
/>
</svg>
{/if}
<style lang="postcss">
.avatar {
border-radius: var(--rounded);
box-shadow: var(--shadow-inset-lg), var(--shadow-raised-lg);
height: var(--size);
width: var(--size);
background-color: var(--color-button-bg);
&--size {
&-xs {
--size: 2.25rem;
box-shadow: var(--shadow-inset), var(--shadow-raised);
border-radius: var(--rounded-sm);
}
&-sm {
--size: 3rem;
box-shadow: var(--shadow-inset), var(--shadow-raised);
border-radius: var(--rounded-sm);
}
&-md {
--size: 6rem;
}
&-lg {
--size: 9rem;
background-color: var(--color-brand-contrast);
}
}
&--float-up {
margin-top: calc(var(--size) * (-2 / 3));
z-index: 1;
}
&--circle {
border-radius: 50%;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
export let label = ''
/** Supports `green`, `yellow`, `red`, & `gray` */
export let color = 'gray'
</script>
<div class="badge badge--color-{color}">
{label}
</div>
<style lang="postcss">
.badge {
font-weight: var(--font-weight-bold);
display: inline;
position: relative;
padding-left: 0.9rem;
line-height: 1rem;
&--color-green {
color: var(--color-badge-green-text);
--color-dot: var(--color-badge-green-dot);
}
&--color-yellow {
color: var(--color-badge-yellow-text);
--color-dot: var(--color-badge-yellow-dot);
}
&--color-red {
color: var(--color-badge-red-text);
--color-dot: var(--color-badge-red-dot);
}
&--color-gray {
color: var(--color-badge-gray-text);
--color-dot: var(--color-badge-gray-dot);
}
&::before {
content: '';
display: inline-block;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--color-dot);
position: absolute;
left: 0;
bottom: 25%;
}
}
</style>

View File

@@ -0,0 +1,151 @@
<script lang="ts">
// TODO: sizes
// TODO: icon only buttons should have uniform padding
// TODO: Could be a class
import { classCombine } from '$package/utils/classCombine'
/** The element to be styled as a button */
export let as: 'button' | 'a' | 'summary' | 'input' = 'button'
export let href = ''
if (href) as = 'a'
/** Use `value` if the button is an `<input`> */
export let value = ''
export let size: 'sm' | 'md' | 'lg' = 'md'
export let color: 'raised' | 'primary' | 'primary-light' | 'danger'| 'danger-light' | 'transparent'
/** Show notification badge in the upper right of button */
export let badge = false
export let disabled = false
let className: string
$: className = classCombine(['button', `button--size-${size}`, `button--color-${color}`, badge && 'has-badge'])
</script>
{#if as === 'button'}
<button class={className} {disabled} on:click>
<slot/>
</button>
{:else if as === 'a'}
<a class={className} {href} {disabled} on:click>
<slot/>
</a>
{:else if as === 'summary'}
<summary class={className} {disabled} on:click>
<slot/>
</summary>
{:else if as === 'input'}
<input class={className} {value} {disabled} on:click/>
{/if}
<style lang="postcss">
.button {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 0.25rem 1rem;
grid-gap: 14px;
cursor: pointer;
position: relative;
box-shadow: var(--shadow-inset-sm);
background-color: var(--color-button-bg);
border-radius: var(--rounded);
transition: opacity 0.5s ease-in-out, filter 0.5s ease-in-out;
&:hover {
background-color: var(--color-button-bg-hover);
}
&--color {
&-raised {
background-color: var(--color-raised-bg);
&:hover {
background-color: var(--color-raised-bg-hover);
}
}
&-primary {
background-color: var(--color-brand);
color: var(--color-brand-contrast);
&:hover {
background-color: var(--color-brand-dark);
}
}
&-primary-light {
background-color: var(--color-brand-light);
transition: filter 0s ease-in-out;
&:hover {
background-color: var(--color-brand-light);
filter: brightness(0.9);
}
}
&-transparent {
background-color: transparent;
box-shadow: none;
}
&-danger {
background-color: var(--color-badge-red-dot);
color: var(--color-brand-contrast);
&:hover {
background-color: var(--color-badge-red-text);
}
}
&-danger-light {
background-color: var(--color-popup-danger-bg);
color: var(--color-popup-danger-text);
transition: filter 0s ease-in-out;
&:hover {
filter: brightness(0.9);
}
}
}
&:disabled {
opacity: 50%;
cursor: not-allowed;
filter: grayscale(50%);
/* Not ideal, but preventing events being fired needs to be implemented */
pointer-events: none;
}
&--pad-even {
padding: 0.5rem;
font-size: 1rem;
line-height: 0;
min-width: 2rem;
min-height: 2rem;
justify-content: center;
}
&.has-badge::after {
content: '';
width: 0.5rem;
height: 0.5rem;
border-radius: var(--rounded-max);
background-color: var(--color-brand);
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
/* Only child doesn't work as intended because text is passed through as `innerText` */
:global(.icon:only-child) {
margin: 4px -6px;
}
}
</style>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import uniqueId from 'lodash.uniqueid'
import IconCheck from 'virtual:icons/heroicons-outline/check'
export let checked = false;
const id = `checkbox-${uniqueId()}`
</script>
<div class="checkbox">
<input type="checkbox" bind:checked={checked} on:change id={id} name={id}>
<label for={id} class="checkbox__label">
<span class="checkbox__label__box">
<IconCheck />
</span>
<slot/>
</label>
</div>
<style lang="postcss">
.checkbox {
input {
display: none;
}
display: inline-flex;
align-items: center;
grid-gap: 0.5rem;
&__label {
display: flex;
align-items: center;
gap: 8px;
> :global(.icon) {
width: 1.1rem;
margin-right: -0.1rem;
}
&__box {
height: 1rem;
width: 1rem;
display: flex;
border-radius: 0.25rem;
background-color: var(--color-button-bg);
justify-content: center;
align-items: center;
> :global(.icon) {
display: none;
width: 1rem;
height: 1rem;
color: var(--color-raised-bg)
}
}
}
[type=checkbox]:checked + label span {
background-color: var(--color-brand);
:global(.icon) {
display: unset;
}
}
}
</style>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Checkbox from './Checkbox.svelte'
import type { SvelteComponent } from 'svelte'
interface Option {
label: string;
/** The element that will be in the `value` array while the option is checked */
value: string | number;
icon: SvelteComponent;
}
export let value = []
export let options: Option[] = []
const handleChange = (e, key) => {
if (e.target.checked) {
if (!value) value = []
value = [key, ...value]
} else {
value = value.filter((it) => key !== it)
}
}
</script>
<div class="checkbox-list">
{#each options as option}
<Checkbox on:change={(e) => handleChange(e, option.value)}>
{#if option.icon}
<svelte:component this={option.icon}/>
{/if}
{option.label}
</Checkbox>
{/each}
</div>
<style lang="postcss">
.checkbox-list {
display: flex;
flex-direction: column;
gap: 2px;
&.wrap {
flex-direction: row;
flex-wrap: wrap;
grid-gap: 2rem;
}
}
</style>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
// TODO: Add fade out styling on top and bottom
import Checkbox from './Checkbox.svelte'
import type { SvelteComponent } from 'svelte'
import VirtualList from 'svelte-tiny-virtual-list'
interface Option {
label: string;
/** The element that will be in the `value` array while the option is checked */
value: string | number;
icon: SvelteComponent;
}
/** Height in pixels of list */
export let height = 200
export let value = []
export let options: Option[] = []
const handleChange = (e, key) => {
if (e.target.checked) {
if (!value) value = []
value = [key, ...value]
} else {
value = value.filter((it) => key !== it)
}
}
</script>
<VirtualList
width="100%"
{height}
itemCount={options.length}
itemSize={26}>
<div slot="item" let:index let:style {style} style:padding-bottom={(options.length) - 1 === index ? '2.5rem' : ''}>
{@const option = options[index]}
<Checkbox checked={value.includes(option.value)} on:change={(e) => handleChange(e, option.value)}>
{#if option.icon}
<svelte:component this={option.icon}/>
{/if}
{option.label}
</Checkbox>
</div>
</VirtualList>

View File

@@ -0,0 +1,48 @@
<script lang="ts">
import Button from "./Button.svelte";
import IconCheck from 'virtual:icons/heroicons-outline/check'
interface Option {
label: string;
value: string | number;
}
export let options: Option[] = [];
export let value: string | number;
// If set to true, one chip is always selected
export let neverEmpty = false;
let selected: Option | null = options.find((option) => option.value === (value || ''));
$: if (selected) {
value = selected.value;
} else {
value = ''
}
</script>
<div class="chips">
{#each options as option}
{@const isSelected = selected?.value === option.value}
<Button
color={isSelected ? 'primary-light' : undefined}
on:click={() => {
isSelected && !neverEmpty
? selected = null
: selected = option
}}
>
{#if isSelected}
<IconCheck />
{/if}
{option.label}
</Button>
{/each}
</div>
<style lang="postcss">
.chips {
display: flex;
grid-gap: 0.5rem;
}
</style>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import uniqueId from 'lodash.uniqueid'
export let required = false;
export let label: string;
const id = `form-field-${uniqueId()}`
</script>
<div class="form-field">
<label for={id} class="text-input__label" class:required>
{label}
</label>
<slot {id} />
</div>
<style lang="postcss">
.form-field {
display: flex;
flex-direction: column;
gap: 8px;
}
</style>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import { page } from '$app/stores';
interface Link {
href: string;
label: string;
}
export let links: Link[];
export let query = '';
export let resetScroll = false
/** Path level in URL, zero-indexed */
export let level = 0;
let path: string[];
$: path = [
...$page.url.pathname
.substring(1)
.split('/')
.map((route) => '/' + route),
'/',
];
$: basePath = path.slice(0, level).join('');
</script>
<nav class="navigation">
{#each links as link}
<a
href={query
? link.href
? `?${query}=${link.href}`
: '?'
: level === 0
? link.href
: basePath + link.href}
on:click={() => { if (resetScroll) document.body.scrollTo(0,0)}}
class="navigation__link"
class:is-active={query
? ($page.url.searchParams.get(query) || '') === link.href
: path[level] === link.href || path[level] === link.href.slice(0, -1)}
sveltekit:noscroll={!resetScroll}
>{link.label}</a
>
{/each}
</nav>
<style lang="postcss">
.navigation {
display: flex;
flex-direction: row;
align-items: center;
grid-gap: 1rem;
flex-wrap: wrap;
&__link {
font-weight: var(--font-weight-bold);
color: var(--color-text-light);
&.is-active {
color: var(--color-text);
position: relative;
&::after {
content: '';
display: block;
position: absolute;
bottom: -0.1rem;
width: 100%;
border-radius: var(--rounded-max);
height: 0.2rem;
background-color: var(--color-brand);
}
}
}
}
</style>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
// TODO: Fix mobile support, currently just cuts off buttons
import IconArrowRight from 'virtual:icons/heroicons-outline/arrow-right'
import IconArrowLeft from 'virtual:icons/heroicons-outline/arrow-left'
import IconMinus from 'virtual:icons/heroicons-outline/minus'
import Button from "./Button.svelte";
import { createEventDispatcher } from 'svelte'
export let page: number;
export let count: number;
$: options = count > 4
? page + 3 >= count
? [1, '-', count - 4, count - 3, count - 2, count - 1, count]
: page > 4
? [1, '-', page - 1, page, page + 1, '-', count]
: [1, 2, 3, 4, 5, '-', count]
: Array.from({ length: count }, (_, i) => i + 1)
const dispatch = createEventDispatcher();
</script>
{#if count > 1}
<div class="pagination">
<Button color="raised" on:click={() => dispatch('change', page - 1)} disabled={page <= 1} title="Last page"><IconArrowLeft height="20px" />
</Button>
{#each options as option}
{#if option === '-'}
<IconMinus class="pagination__dash" />
{:else}
<Button color={option === page ? 'primary' : 'raised'} on:click={() => dispatch('change', option)} evenPadding={true}>{option}</Button>
{/if}
{/each}
<Button color="raised" on:click={() => dispatch('change', page + 1)} disabled={page >= count} title="Next page">
<IconArrowRight height="20px" />
</Button>
</div>
{/if}
<style lang="postcss">
.pagination {
align-self: center;
display: flex;
grid-gap: 0.5rem;
align-items: center;
:global(&__dash) {
margin: 0 0.5rem;
width: 1rem;
height: 1rem;
}
@media (width <= 500px) {
grid-gap: 0.25rem;
:global(> *:nth-child(4)), :global(> *:nth-child(6)) {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,204 @@
<script lang="ts">
import IconChevronDown from 'virtual:icons/lucide/chevron-down'
import { debounce } from 'throttle-debounce'
import { clickOutside } from 'svelte-use-click-outside'
import { onMount } from 'svelte'
interface Option {
label: string;
value: string | number;
}
export let options: Option[] = []
export let value: string | number
export let selected: Option = options.find((option) => option.value === (value || ''))
export let color = ''
export let label = ''
export let icon = null
let open = false
$: if (selected) {
value = selected.value
}
const minWidth = options
.map(it => it.label || it.value)
.reduce((it, acc) => String(it).length > acc ? it : acc, '')
.length * 9
let shouldOpenUp = false
let element: HTMLElement
const checkShouldOpenUp = debounce(100, false, () => {
if (element) {
const bounding = element.getBoundingClientRect()
shouldOpenUp =
bounding.bottom + 32 * options.length + 16 >
(window.innerHeight || document.documentElement.clientHeight)
}
})
onMount(() => {
checkShouldOpenUp()
window.addEventListener('resize', checkShouldOpenUp)
})
function keydown(event: KeyboardEvent) {
if ((event.key === ' ' || event.key === 'Enter') && !open) {
open = true
} else if (event.key === 'ArrowUp') {
if (selected) {
const index = options.findIndex((option) => option.value === selected.value)
if (index > 0) {
selected = options[index - 1]
}
}
} else if (event.key === 'ArrowDown') {
if (selected) {
const index = options.findIndex((option) => option.value === selected.value)
if (index < options.length - 1) {
selected = options[index + 1]
}
}
} else if ((event.key === 'Escape' || event.key === 'Enter') && open) {
open = false
}
}
</script>
<div
class="select select--color-{color}"
class:is-open={open}
class:select--opens-up={shouldOpenUp}
use:clickOutside={() => {
open = false;
}}
bind:this={element}
tabindex="0"
on:keydown={keydown}
>
<div
class="select__input"
on:click={() => {
open = !open;
}}
>
{#if icon}
<svelte:component this={icon}/>
{/if}
<span class="select__input__value" style:min-width="{minWidth}px">{label || selected?.label || value || 'Choose...'}</span>
{#if !icon}
<div class="select__input__arrow">
<slot name="expandIcon">
<IconChevronDown/>
</slot>
</div>
{/if}
</div>
{#if open}
<ul class="select__options">
{#each options as option (option.value)}
<li
on:click={() => {
selected = option;
open = false;
}}
class:is-selected={selected?.value === option.value}
>
{option.label || option.value}
</li>
{/each}
</ul>
{/if}
</div>
<style lang="postcss">
.select {
position: relative;
display: flex;
flex-direction: column;
color: var(--color-button-text);
cursor: pointer;
border-radius: var(--rounded);
align-self: flex-start;
&__input {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.9rem;
grid-gap: 0.4rem;
background-color: var(--color-button-bg);
box-shadow: var(--shadow-inset-sm);
border-radius: var(--rounded);
}
&__options {
list-style-type: none;
display: flex;
flex-direction: column;
width: 100%;
padding: 0;
margin: 0;
position: absolute;
top: 100%;
background-color: var(--color-button-bg);
border-radius: var(--rounded-bottom);
box-shadow: var(--shadow-inset-sm), var(--shadow-floating);
overflow: hidden;
border-top: 0.1rem solid var(--color-divider);
z-index: 5;
li {
padding: 0.25rem 1rem;
&:hover {
background-color: var(--color-button-bg-hover);
}
&.is-selected {
background-color: var(--color-brand-dark);
color: var(--color-brand-contrast);
cursor: default;
}
}
}
&.is-open {
z-index: 10;
.select__input {
border-radius: var(--rounded-top);
box-shadow: none;
&__arrow {
transform: rotate(180deg);
}
}
}
&--color-raised {
> * {
background-color: var(--color-raised-bg);
}
}
&--opens-up {
.select__options {
bottom: 100%;
top: auto;
border-radius: var(--rounded-top);
box-shadow: none;
border-top: none;
border-bottom: 0.1rem solid var(--color-divider);
}
&.is-open .select__input {
border-radius: var(--rounded-bottom);
}
}
}
</style>

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import type { SvelteComponent } from 'svelte'
export let placeholder = ''
export let icon: SvelteComponent = ''
export let value = ''
export let multiline = false
export let id: string = undefined
</script>
<div class="text-input">
{#if multiline}
<textarea name={id} {placeholder} bind:value={value}/>
{:else}
<input type="text" name={id} {placeholder} bind:value={value}/>
{/if}
</div>
<style lang="postcss">
.text-input {
display: flex;
flex-direction: column;
gap: 8px;
input, textarea {
border-radius: var(--rounded-sm);
box-shadow: var(--shadow-inset-sm);
background-color: var(--color-button-bg);
border: none;
padding: 0.25rem 0.75rem;
width: 20rem;
max-width: 100%;
}
&--fill {
width: 100%;
padding: 0.5rem 0.75rem;
}
textarea {
min-height: 2.5rem;
}
}
</style>

26
src/package/index.ts Normal file
View File

@@ -0,0 +1,26 @@
export { default as Avatar } from './components/Avatar.svelte';
export { default as Badge } from './components/Badge.svelte';
export { default as Button } from './components/Button.svelte';
export { default as Checkbox } from './components/Checkbox.svelte';
export { default as CheckboxList } from './components/CheckboxList.svelte';
export { default as CheckboxVirtualList } from './components/CheckboxVirtualList.svelte';
export { default as Chips } from './components/Chips.svelte';
export { default as FormField } from './components/FormField.svelte';
export { default as NavRow } from './components/NavRow.svelte';
export { default as Pagination } from './components/Pagination.svelte';
export { default as Select } from './components/Select.svelte';
export { default as TextInput } from './components/TextInput.svelte';

View File

@@ -0,0 +1,4 @@
@import-glob "./styles/normalize.postcss"; /* Doesn't need to glob but fixes warning */
@import-glob "./styles/themes/**.postcss";
@import-glob "./styles/variables/**.postcss";
@import-glob "./styles/classes/**.postcss";

View File

@@ -0,0 +1,20 @@
.actions {
display: flex;
flex-direction: column;
align-items: flex-end;
grid-gap: 0.5rem;
flex-wrap: wrap;
margin-left: auto;
min-width: fit-content;
> *:last-child {
margin-top: auto;
color: var(--color-text-light);
}
@media (width <= 1000px) {
flex-direction: row;
align-items: flex-start;
margin-left: unset;
}
}

View File

@@ -0,0 +1,26 @@
.base {
display: flex;
justify-content: center;
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-standard);
font-size: var(--font-size-nm);
font-weight: var(--font-weight-regular);
padding: 1rem;
scrollbar-color: var(--color-scrollbar) var(--color-bg);
&::-webkit-scrollbar {
width: 14px;
}
&::-webkit-scrollbar-track {
background-color: var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-scrollbar);
border-radius: 999px;
border: 3px solid var(--color-bg);
}
}

View File

@@ -0,0 +1,5 @@
.button-group {
display: flex;
grid-gap: 0.5rem;
flex-wrap: wrap;
}

View File

@@ -0,0 +1,101 @@
.card {
--padding: 1rem;
display: flex;
flex-direction: column;
position: relative;
background-color: var(--color-raised-bg);
border-radius: var(--rounded);
overflow: hidden;
box-shadow: var(--shadow-raised), var(--shadow-inset);
padding: var(--padding);
grid-gap: 1rem;
max-width: 100%;
.profile-picture {
z-index: 1;
}
&--gap {
&-compressed {
grid-gap: 0.6rem;
}
&-none {
grid-gap: 0;
}
}
&--pad {
&-top {
padding-top: 2.5rem;
}
}
&--overflow-visible {
overflow: visible;
}
&__banner {
--inverse-padding: calc(var(--padding) * -1);
margin: var(--inverse-padding) var(--inverse-padding) 0 var(--inverse-padding);
z-index: 0;
background-color: var(--color-divider);
&:-moz-loading {
visibility: hidden;
}
&--short {
height: 6.5rem;
object-fit: cover;
object-position: center;
}
&--dark {
filter: brightness(0.7);
}
}
&__overlay {
position: absolute;
top: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
align-items: flex-end;
grid-gap: 0.5rem;
z-index: 1;
&--row {
flex-direction: row;
}
}
&--row {
flex-direction: row;
align-items: center;
}
&--strip {
flex-direction: row;
justify-content: space-between;
align-items: center;
}
&--pad-x {
--padding: 1rem 1.3rem;
}
&.text {
--padding: 1.5rem;
}
p {
line-height: 130%;
}
}

View File

@@ -0,0 +1,5 @@
.divider {
margin: 0.25rem 0;
border: none;
border-top: 1px solid var(--color-divider);
}

View File

@@ -0,0 +1,16 @@
.illustration {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
grid-gap: 2rem;
&__image {
max-width: 300px;
}
&__description {
font-size: 1.2rem;
color: var(--color-text-light)
}
}

View File

@@ -0,0 +1,11 @@
.info-table {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-gap: 0.25rem 2rem;
width: fit-content;
&__label {
color: var(--color-text-lightest);
font-weight: var(--font-weight-medium);
}
}

View File

@@ -0,0 +1,26 @@
.link {
color: var(--color-link);
display: flex;
align-items: center;
gap: 4px;
line-height: 100%;
&:hover {
text-decoration: underline;
}
}
.link-group {
display: grid;
grid-template-columns: repeat(3, auto);
grid-gap: 0.75rem;
.link {
color: var(--color-text);
&:hover {
color: var(--color-link);
}
}
}

View File

@@ -0,0 +1,137 @@
.markdown {
display: flex;
flex-direction: column;
grid-gap: 1rem;
blockquote,
details,
dl,
ol,
p,
code,
pre,
table,
ul {
margin: 0;
}
p {
line-height: 1.5;
word-wrap: break-word;
overflow-wrap: anywhere;
}
h1,
h2 {
padding-bottom: 0.2em;
border-bottom: 1px solid var(--color-divider);
}
blockquote {
padding: 0 1rem;
color: var(--color-text);
border-left: 0.25rem solid var(--color-divider);
}
a {
color: var(--color-link);
&:hover {
text-decoration: underline;
}
}
img,
iframe {
max-width: 100%;
height: auto;
border-radius: var(--rounded-sm);
}
iframe {
width: 35rem;
aspect-ratio: 16/9;
}
code {
padding: 0.2rem 0.4rem;
font-size: 80%;
border-radius: var(--rounded-sm);
background-color: var(--color-code-bg);
color: var(--color-code-text);
}
pre {
padding: 1rem;
border-radius: var(--rounded-sm);
overflow-x: auto;
code {
font-size: 80%;
padding: 0;
border-radius: 0;
background-color: unset;
}
}
hr {
margin: 0;
color: var(--color-divider);
}
table {
display: block;
width: max-content;
max-width: 100%;
overflow: auto;
border-collapse: collapse;
line-height: 1.5;
th {
font-weight: 600;
}
td,
th {
padding: 0.4rem 0.85rem;
border: 0.1rem solid var(--color-table-border);
}
tr:nth-child(2n) {
background-color: var(--color-table-alternate-row);
}
}
details {
border: 0.15rem solid var(--color-button-bg);
border-radius: var(--rounded-sm);
padding: 0.5rem 0.5rem 0;
overflow: hidden;
summary {
font-weight: bold;
margin: -0.5rem -0.5rem 0;
padding: 0.5rem 0.8rem;
cursor: pointer;
background-color: var(--color-button-bg);
&:hover {
background-color: var(--color-button-bg-hover);
}
}
&[open] {
padding: 0.5rem;
summary {
margin-bottom: 0.5rem;
}
}
}
li:has(> input) {
list-style: none;
margin: 0;
padding: 0;
}
}

View File

@@ -0,0 +1,13 @@
.member {
display: flex;
grid-gap: 0.75rem;
&__info {
display: flex;
flex-direction: column;
&__link {
font-weight: var(--font-weight-medium);
}
}
}

View File

@@ -0,0 +1,23 @@
.stat {
display: flex;
align-items: baseline;
grid-gap: 0.4rem;
&--light {
color: var(--color-text-lightest);
}
.icon {
align-self: center;
}
strong {
font-size: 20px;
}
}
.stat-group {
display: flex;
grid-gap: 0.5rem;
flex-wrap: wrap;
}

View File

@@ -0,0 +1,18 @@
.tag {
display: flex;
align-items: center;
grid-gap: 0.25rem;
color: var(--color-text-lightest);
svg {
width: 1rem;
height: auto;
}
}
.tag-group {
display: inline-flex;
flex-wrap: wrap;
margin-top: auto;
grid-gap: 0.25rem 0.6rem;
}

View File

@@ -0,0 +1,14 @@
.title-primary {
font-size: 24px;
font-weight: var(--font-weight-bold);
}
.title-secondary {
font-size: 20px;
font-weight: var(--font-weight-bold);
}
.title-tertiary {
font-size: 16px;
font-weight: var(--font-weight-bold);
}

View File

@@ -0,0 +1,55 @@
@import 'sanitize.css';
@import 'sanitize.css/forms.css';
@import 'sanitize.css/typography.css';
/* Overrides */
button {
margin: 0;
padding: 0;
font-size: inherit;
box-shadow: none;
border: none;
cursor: pointer;
}
a {
color: inherit;
text-decoration: none;
}
*:focus {
outline: none;
}
button:focus-visible,
a:focus-visible,
[tabindex='0']:focus-visible {
outline: 0.2rem solid var(--color-brand);
}
html,
body,
#svelte {
height: 100%;
}
html {
overflow-y: hidden;
overflow-x: hidden;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
line-height: 100%;
margin: 0;
}
ul {
padding: 0 0 0 1.5rem;
}

View File

@@ -0,0 +1,17 @@
:root {
--rounded: 1rem;
--rounded-top: 1rem 1rem 0 0;
--rounded-bottom: 0 0 1rem 1rem;
--rounded-sm: 0.6rem;
--rounded-max: 999999999px;
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
--font-size-nm: 1rem; /* 16px */
--font-size-xl: 1.5rem; /* 24px */
--font-weight-regular: 400;
--font-weight-medium: 600;
--font-weight-bold: 700;
}

View File

@@ -0,0 +1,61 @@
.theme-dark {
/* Brand colors */
--color-brand: hsl(155, 58%, 40%);
--color-brand-light: hsl(155, 54%, 30%);
--color-brand-dark: hsl(155, 58%, 25%);
--color-brand-contrast: hsl(0, 0%, 100%);
/* Shadows */
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
--shadow-inset-sm: inset 0px -1px 1px hsla(221, 39%, 11%, 0.25);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
--shadow-mobile-bar: hsla(0, 0%, 0%, 0.3) 0 0 20px 2px;
/* Text colors */
--color-text: hsl(221, 39%, 90%);
--color-text-light: hsl(215, 14%, 74%);
--color-text-lightest: hsl(220, 9%, 70%);
--color-heading: hsl(222, 16%, 80%);
--color-link: hsl(215, 100%, 75%);
/* Container colors */
--color-bg: hsl(220, 13%, 15%);
--color-raised-bg: hsl(220, 13%, 25%);
--color-raised-bg-hover: hsl(220, 13%, 20%);
--color-divider: hsl(220, 13%, 50%);
--color-button-bg: hsl(220, 13%, 35%);
--color-button-bg-hover: hsl(220, 13%, 32%);
/* Label colors */
--color-badge-gray-text: hsl(0, 2%, 69%);
--color-badge-gray-dot: hsl(0, 6%, 77%);
--color-badge-red-text: hsl(343, 63%, 67%);
--color-badge-red-dot: hsl(342, 70%, 53%);
--color-badge-green-text: hsl(156, 53%, 50%);
--color-badge-green-dot: hsl(140, 64%, 40%);
--color-badge-yellow-text: hsl(40, 57%, 60%);
--color-badge-yellow-dot: hsl(40, 92%, 62%);
/* Markdown colors */
--color-table-border: hsl(214, 12%, 35%);
--color-table-alternate-row: hsl(216, 12%, 17%);
--color-code-bg: hsl(217, 12%, 29%);
/* Ad colors */
--color-ad-bg: hsl(200, 70%, 25%);
--color-ad-link: hsl(200, 70%, 50%);
/* Popup colors */
--color-popup-danger-bg: hsl(355, 70%, 20%);
--color-popup-danger-text: hsl(342, 70%, 75%);
--color-input-light: hsl(220, 13%, 20%);
/* Scrollbar color */
--color-scrollbar-thumb: hsl(220, 13%, 40%);
}

View File

@@ -0,0 +1,60 @@
.theme-light {
/* Brand colors */
--color-brand: hsl(155, 58%, 44%);
--color-brand-light: hsl(135, 50%, 78%);
--color-brand-dark: hsl(155, 58%, 38%);
--color-brand-contrast: hsl(0, 0%, 100%);
/* Shadows */
--shadow-inset-lg: inset 0px -2px 2px hsla(221, 39%, 11%, 0.1);
--shadow-inset: inset 0px -2px 2px hsla(221, 39%, 11%, 0.05);
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
--shadow-raised: 0px 2px 4px hsla(221, 39%, 11%, 0.1);
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
--shadow-mobile-bar: hsla(0, 0%, 0%, 0.3) 0 0 20px 2px;
/* Text colors */
--color-text: hsl(221, 39%, 11%);
--color-text-light: hsl(215, 14%, 34%);
--color-text-lightest: hsl(220, 9%, 46%);
--color-heading: hsl(222, 16%, 20%);
--color-link: hsl(221, 55%, 50%);
/* Container colors */
--color-bg: hsl(220, 13%, 91%);
--color-raised-bg: hsl(0, 0%, 100%);
--color-raised-bg-hover: hsl(220, 13%, 93%);
--color-divider: hsl(220, 13%, 91%);
--color-button-bg: hsl(220, 13%, 91%);
--color-button-bg-hover: hsl(0, 0%, 85%);
--color-input-text-light: hsl(0, 0%, 94%);
/* Label colors */
--color-badge-gray-text: hsl(0, 2%, 39%);
--color-badge-gray-dot: hsl(0, 6%, 77%);
--color-badge-red-text: hsl(343, 63%, 27%);
--color-badge-red-dot: hsl(342, 70%, 53%);
--color-badge-green-text: hsl(156, 53%, 20%);
--color-badge-green-dot: hsl(140, 64%, 40%);
--color-badge-yellow-text: hsl(40, 57%, 29%);
--color-badge-yellow-dot: hsl(40, 92%, 62%);
/* Markdown colors */
--color-table-border: hsl(210, 10%, 89%);
--color-table-alternate-row: hsl(210, 29%, 97%);
--color-code-bg: hsl(210, 29%, 96%);
/* Ad colors */
--color-ad-bg: hsl(200, 70%, 82%);
--color-ad-link: hsl(200, 80%, 40%);
/* Popup colors */
--color-popup-danger-bg: hsl(355, 70%, 88%);
--color-popup-danger-text: hsl(342, 70%, 35%);
/* Scrollbar color */
--color-scrollbar-thumb: hsl(220, 13%, 70%);
}

View File

@@ -0,0 +1,15 @@
.oled-theme {
@extend .dark-theme;
/* Container colors */
--color-bg: hsl(220, 13%, 0%);
--color-raised-bg: hsl(220, 13%, 10%);
--color-raised-bg-hover: hsl(220, 13%, 20%);
--color-divider: hsl(220, 13%, 35%);
--color-button-bg: hsl(220, 13%, 20%);
--color-button-bg-hover: hsl(220, 13%, 15%);
/* Ad colors */
--color-ad-bg: hsl(200, 70%, 15%);
--color-ad-link: hsl(200, 70%, 45%);
}

View File

@@ -0,0 +1,16 @@
:root {
/* Borders */
--border-width: 1px;
--border-style: solid;
--border: var(--border-width) var(--border-style);
/* Rounded radii */
--rounded-sm: 8px;
--rounded: 10px;
--rounded-lg: 12px;
--rounded-max: 100px;
--rounded-top: var(--rounded) var(--rounded) 0 0;
--rounded-bottom: 0 0 var(--rounded) var(--rounded);
--rounded-sm-top: var(--rounded-sm) var(--rounded-sm) 0 0;
--rounded-sm-bottom: 0 0 var(--rounded-sm) var(--rounded-sm);
}

View File

@@ -0,0 +1,213 @@
:root {
/* these are values for the display CSS property */
/*
--display-values: (
block,
flex,
inline,
inline-block,
inline-flex,
none,
table,
table-cell
);
// maps edges to respective corners for border-radius
--edges: (
top: (top-left, top-right),
right: (top-right, bottom-right),
bottom: (bottom-right, bottom-left),
left: (bottom-left, top-left)
);
*/
/*
These are our margin and padding utility spacers. The default step size we
use is 8px. This gives us a key of:
0 => 0px
1 => 4px
2 => 8px
3 => 16px
4 => 24px
5 => 32px
6 => 40px */
--spacer: 8px;
/* Our spacing scale */
--spacer-0: 0; /* 0 */
--spacer-1: calc(var(--spacer) * 0.5); /* 4px */
--spacer-2: --spacer; /* 8px */
--spacer-3: calc(var(--spacer) * 2); /* 16px */
--spacer-4: calc(var(--spacer) * 3); /* 24px */
--spacer-5: calc(var(--spacer) * 4); /* 32px */
--spacer-6: calc(var(--spacer) * 5); /* 40px */
/*
/* The list of spacer values
--spacers: (
--spacer-0,
--spacer-1,
--spacer-2,
--spacer-3,
--spacer-4,
--spacer-5,
--spacer-6,
);
/* And the map of spacers, for easier looping:
/* @each --scale, --length in --spacer-map { ... }
--spacer-map: (
0: --spacer-0,
1: --spacer-1,
2: --spacer-2,
3: --spacer-3,
4: --spacer-4,
5: --spacer-5,
6: --spacer-6,
);
/* Increases the core spacing scale first by 8px for --spacer-7, then by 16px
/* increments from --spacer-8 to --spacer-12, i.e. after 40px, we have 48, 64,
/* 80, 96, etc.
--spacer-7: --spacer * 6; /* 48px
--spacer-8: --spacer * 8; /* 64px
--spacer-9: --spacer * 10; /* 80px
--spacer-10: --spacer * 12; /* 96px
--spacer-11: --spacer * 14; /* 112px
--spacer-12: --spacer * 16; /* 128px
--spacers-large: (
7: --spacer-7,
8: --spacer-8,
9: --spacer-9,
10: --spacer-10,
11: --spacer-11,
12: --spacer-12,
);
--spacer-map-extended: map-merge(
(
0: 0,
1: --spacer-1,
2: --spacer-2,
3: --spacer-3,
4: --spacer-4,
5: --spacer-5,
6: --spacer-6,
),
--spacers-large,
);
/* Em spacer variables
--em-spacer-1: 0.0625em; /* 1/16
--em-spacer-2: 0.125em; /* 1/8
--em-spacer-3: 0.25em; /* 1/4
--em-spacer-4: 0.375em; /* 3/8
--em-spacer-5: 0.5em; /* 1/2
--em-spacer-6: 0.75em; /* 3/4
/* Size scale
/* Used for buttons, inputs, labels, avatars etc.
--size: 16px;
--size-0: 0;
--size-1: --size; /* 16px
--size-2: --size-1 + 4px; /* 20px
--size-3: --size-2 + 4px; /* 24px
--size-4: --size-3 + 4px; /* 28px
--size-5: --size-4 + 4px; /* 32px
--size-6: --size-5 + 8px; /* 40px
--size-7: --size-6 + 8px; /* 48px
--size-8: --size-7 + 16px; /* 64px
/* Fixed-width container variables
--container-width: 980px;
--grid-gutter: 10px;
// Breakpoint widths
--width-xs: 0;
// Small screen / phone
--width-sm: 544px;
// Medium screen / tablet
--width-md: 768px;
// Large screen / desktop (980 + (16 * 2)) <= container + gutters
--width-lg: 1012px;
// Extra large screen / wide desktop
--width-xl: 1280px;
// Responsive container widths
--container-sm: --width-sm;
--container-md: --width-md;
--container-lg: --width-lg;
--container-xl: --width-xl;
// Breakpoints in the form (name: length)
--breakpoints: (
sm: --width-sm,
md: --width-md,
lg: --width-lg,
xl: --width-xl
);
// This map in the form (breakpoint: variant) is used to iterate over
// breakpoints and create both responsive and non-responsive classes in one
// loop:
--responsive-variants: (
'': '',
sm: '-sm',
md: '-md',
lg: '-lg',
xl: '-xl',
);
// responsive utility position values
--responsive-positions: (
static,
relative,
absolute,
fixed,
sticky
);
--sidebar-width: (
sm: 220px,
md: 256px,
lg: 296px
);
--sidebar-narrow-width: (
md: 240px,
lg: 256px
);
--sidebar-wide-width: (
lg: 320px,
xl: 336px
);
--gutter: (
md: --spacer-3,
lg: --spacer-4,
xl: --spacer-5
);
--gutter-condensed: (
md: --spacer-3,
lg: --spacer-3,
xl: --spacer-4
);
--gutter-spacious: (
md: --spacer-4,
lg: --spacer-5,
xl: --spacer-6
);
*/
}
/* Breakpoints */
@custom-media --sm (min-width: 544px);
@custom-media --md (min-width: 768px);
@custom-media --lg (min-width: 1012px);
@custom-media --xl (min-width: 544px);

View File

@@ -0,0 +1,38 @@
:root {
/* Heading sizes - mobile */
/* h4-h6 remain the same size on both mobile & desktop */
--h00-size-mobile: 40px;
--h0-size-mobile: 32px;
--h1-size-mobile: 26px;
--h2-size-mobile: 22px;
--h3-size-mobile: 18px;
/* Heading sizes - desktop */
--h00-size: 48px;
--h0-size: 40px;
--h1-size: 32px;
--h2-size: 24px;
--h3-size: 20px;
--h4-size: 16px;
--h5-size: 14px;
--h6-size: 12px;
--font-size-lg: 17px;
--font-size-sm: 12px;
--font-size: 14px;
/* Line heights */
--lh-condensed-ultra: 1;
--lh-condensed: 1.25;
--lh-default: 1.5;
/* Font weights */
--font-weight-light: 300;
--font-weight-normal: 400;
--font-weight-semibold: 500;
--font-weight-bold: 600;
/* Font stacks */
--body-font: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji';
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
}

57
src/package/utils/ago.ts Normal file
View File

@@ -0,0 +1,57 @@
/**
* Human readable elapsed or remaining time (example: 3 minutes ago)
* @author github.com/victornpb
* @see https://stackoverflow.com/a/67338038/938822
*/
export function ago(
/** A Date object, timestamp or string parsable with Date.parse() */
date: string | number | Date,
/** A Date object, timestamp or string parsable with Date.parse() */
nowDate: string | number | Date = Date.now(),
/** A Intl formater */
rft: Intl.RelativeTimeFormat = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
): string {
const SECOND = 1000;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const WEEK = 7 * DAY;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
const intervals = [
{ ge: YEAR, divisor: YEAR, unit: 'year' },
{ ge: MONTH, divisor: MONTH, unit: 'month' },
{ ge: WEEK, divisor: WEEK, unit: 'week' },
{ ge: DAY, divisor: DAY, unit: 'day' },
{ ge: HOUR, divisor: HOUR, unit: 'hour' },
{ ge: MINUTE, divisor: MINUTE, unit: 'minute' },
{ ge: 30 * SECOND, divisor: SECOND, unit: 'seconds' },
{ ge: 0, divisor: 1, text: 'just now' },
];
const now = typeof nowDate === 'object' ? nowDate.getTime() : new Date(nowDate).getTime();
const diff = now - (typeof date === 'object' ? date : new Date(date)).getTime();
const diffAbs = Math.abs(diff);
for (const interval of intervals) {
if (diffAbs >= interval.ge) {
const x = Math.round(Math.abs(diff) / interval.divisor);
const isFuture = diff < 0;
return interval.unit ? rft.format(isFuture ? x : -x, interval.unit as Unit) : interval.text;
}
}
}
type Unit =
| 'second'
| 'seconds'
| 'minute'
| 'minutes'
| 'hour'
| 'hours'
| 'day'
| 'days'
| 'week'
| 'weeks'
| 'month'
| 'months'
| 'year'
| 'years';

View File

@@ -0,0 +1,3 @@
export function classCombine(names) {
return names.filter(name => name && !name.includes('undefined')).join(' ')
}

View File

@@ -0,0 +1,40 @@
/**
* Convert large numbers to human readable strings
* @source https://github.com/rohmanhm/simplify-number
*/
export function simplify(num = 0): string {
let numberVar = num;
// 2 decimal places => 100, 3 => 1000, etc
const decPlaces = Math.pow(10, 1);
// Enumerate number abbreviations
const abbrev = ['K', 'M', 'B', 'T'];
// Go through the array backwards, so we do the largest first
for (let i = abbrev.length - 1; i >= 0; i--) {
// Convert array index to "1000", "1000000", etc
const size = Math.pow(10, (i + 1) * 3);
// If the number is bigger or equal do the abbreviation
if (size <= numberVar) {
// Here, we multiply by decPlaces, round, and then divide by decPlaces.
// This gives us nice rounding to a particular decimal place.
numberVar = Math.round((numberVar * decPlaces) / size) / decPlaces;
// Handle special case where we round up to the next abbreviation
if (numberVar === 1000 && i < abbrev.length - 1) {
numberVar = 1;
i++;
}
// Add the letter for the abbreviation
(numberVar as any) += abbrev[i];
// We are done... stop
break;
}
}
return String(numberVar);
}

134
src/package/utils/parse.ts Normal file
View File

@@ -0,0 +1,134 @@
import { marked } from 'marked';
import hljs from 'highlight.js';
import insane from 'insane';
const renderer = new marked.Renderer();
renderer.image = (href, text) => {
if (/^https?:\/\/(www\.)?youtube\.com\/watch\?v=[a-zA-Z0-9_]{11}$/.test(href)) {
const id = href.substring(32, 43);
return `<iframe src="https://www.youtube-nocookie.com/embed/${id}?&modestbranding=1&autoplay=0&rel=0" frameborder="0" allowfullscreen></iframe>`;
} else {
return `<img src="${href}" alt="${text}" />`;
}
};
renderer.link = (href, title, text) => {
if (href === null) {
return text;
}
let out = '<a href="' + href + '" rel="external nofollow"';
if (title) {
out += ' title="' + title + '"';
}
out += '>' + text + '</a>';
return out;
};
marked.setOptions({
renderer,
highlight: function (code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
langPrefix: 'hljs language-',
headerPrefix: '',
gfm: true,
smartLists: true,
});
function sanitize(html: string): string {
return insane(html, {
allowedAttributes: {
a: ['href', 'name', 'target', 'title', 'rel'],
iframe: ['allowfullscreen', 'src', 'width', 'height'],
img: ['src', 'width', 'height', 'alt'],
h1: ['id'],
h2: ['id'],
h3: ['id'],
h4: ['id'],
h5: ['id'],
h6: ['id'],
code: ['class'],
span: ['class'],
input: ['type', 'checked', 'disabled'],
font: ['color'],
},
allowedClasses: {},
allowedSchemes: ['http', 'https', 'mailto'],
allowedTags: [
'a',
'b',
'blockquote',
'br',
'caption',
'center',
'code',
'del',
'details',
'div',
'em',
'font',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'hr',
'i',
'iframe',
'img',
'input',
'ins',
'kbd',
'li',
'main',
'ol',
'p',
'pre',
'span',
'strike',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'u',
'ul',
],
filter: ({ tag, attrs }): boolean => {
if (tag === 'iframe') {
return /^https?:\/\/(www\.)?(youtube|youtube-nocookie)\.com\/embed\/[a-zA-Z0-9_]{11}(\?)?(&modestbranding=1)?(&autoplay=0)?(&loop=1)?(&playlist=[a-zA-Z0-9_]{11})?(&rel=0)?$/.test(
attrs.src || ''
);
} else if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tag)) {
return attrs.id !== 'svelte';
} else if (tag === 'input') {
return attrs.type === 'checkbox' && attrs.disabled === '';
} else if (tag === 'code' || tag === 'span') {
return !attrs.class || attrs.class.replace(' ', '').startsWith('hljs');
} else {
return true;
}
},
transformText: null,
});
}
export function markdown(markdown: string): string {
return marked.parse(markdown);
}
export function markdownInline(markdown: string): string {
return marked.parseInline(markdown);
}
export function markdownXSS(markdown: string): string {
return sanitize(marked.parse(markdown));
}

View File

@@ -0,0 +1,38 @@
export class Permissions {
data = {
uploadVersions: false,
deleteVersion: false,
editDetails: false,
editBody: false,
manageInvites: false,
removeMember: false,
editMember: false,
deleteProject: false,
};
get settingsPage(): boolean {
return (
this.data.manageInvites ||
this.data.removeMember ||
this.data.editMember ||
this.data.deleteProject
);
}
constructor(from: number | 'ALL' | null) {
if (from === 'ALL' || from === 0b11111111 || from === null) {
Object.keys(this.data).forEach((v) => (this.data[v] = true));
} else if (typeof from === 'number') {
this.data = {
uploadVersions: !!(from & (1 << 0)),
deleteVersion: !!(from & (1 << 1)),
editDetails: !!(from & (1 << 2)),
editBody: !!(from & (1 << 3)),
manageInvites: !!(from & (1 << 4)),
removeMember: !!(from & (1 << 5)),
editMember: !!(from & (1 << 6)),
deleteProject: !!(from & (1 << 7)),
};
}
}
}

View File

@@ -0,0 +1,84 @@
import gameVersions from '$generated/gameVersions.json';
export function formatVersions(versionArray: string[]): string {
const allVersions = gameVersions.slice().reverse();
const allReleases = allVersions.filter((x) => x.version_type === 'release');
const intervals = [];
let currentInterval = 0;
for (let i = 0; i < versionArray.length; i++) {
const index = allVersions.findIndex((x) => x.version === versionArray[i]);
const releaseIndex = allReleases.findIndex((x) => x.version === versionArray[i]);
if (i === 0) {
intervals.push([[versionArray[i], index, releaseIndex]]);
} else {
const intervalBase = intervals[currentInterval];
if (
(index - intervalBase[intervalBase.length - 1][1] === 1 ||
releaseIndex - intervalBase[intervalBase.length - 1][2] === 1) &&
(allVersions[intervalBase[0][1]].version_type === 'release' ||
allVersions[index].version_type !== 'release')
) {
intervalBase[1] = [versionArray[i], index, releaseIndex];
} else {
currentInterval += 1;
intervals[currentInterval] = [[versionArray[i], index, releaseIndex]];
}
}
}
const newIntervals = [];
for (let i = 0; i < intervals.length; i++) {
const interval = intervals[i];
if (interval.length === 2 && interval[0][2] !== -1 && interval[1][2] === -1) {
let lastSnapshot = null;
for (let j = interval[1][1]; j > interval[0][1]; j--) {
if (allVersions[j].version_type === 'release') {
newIntervals.push([
interval[0],
[
allVersions[j].version,
j,
allReleases.findIndex((x) => x.version === allVersions[j].version),
],
]);
if (lastSnapshot !== null && lastSnapshot !== j + 1) {
newIntervals.push([[allVersions[lastSnapshot].version, lastSnapshot, -1], interval[1]]);
} else {
newIntervals.push([interval[1]]);
}
break;
} else {
lastSnapshot = j;
}
}
} else {
newIntervals.push(interval);
}
}
const output = [];
for (const interval of newIntervals) {
if (interval.length === 2) {
output.push(`${interval[0][0]}${interval[1][0]}`);
} else {
output.push(interval[0][0]);
}
}
return output.join(', ');
}
export const getPrimary = (files: Version['files']) =>
files.find((file) => file.primary) || files[0];
export function downloadUrl(file): string {
return import.meta.env.VITE_API_URL + `version_file/${file?.hashes.sha1}/download`;
}