Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added action that copies event link to clipboard #311

Merged
merged 10 commits into from
Mar 3, 2022
79 changes: 62 additions & 17 deletions src/lib/components/events/event-item.svelte
Original file line number Diff line number Diff line change
@@ -1,28 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { AcmEvent } from '$lib/ical/parse';
import { toast, ToastType } from '$lib/stores/toasts';
import CopyLinkIcon from '$lib/components/icons/copy-link.svelte';

export let info: AcmEvent;

let isRecurring: boolean = info.recurring;
let anchor: HTMLElement;
let details: HTMLDetailsElement;

function copyEventLink(event: AcmEvent) {
const eventLink = location.origin + location.pathname + '#' + event.slug;

// @see <https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/writeText>
navigator.clipboard
.writeText(eventLink)
.then(() => toast({ content: 'Copied event link to clipboard!', path: event.acmPath.slug }))
.catch(() =>
toast({
type: ToastType.Error,
path: event.acmPath.slug,
content: 'Failed to copy event link to clipboard!',
})
);
}

onMount(() => {
if (location.hash === `#${info.slug}`) {
anchor.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'center',
});

// This has the same styling as :target, and :target doesn't want to work
// for dynamically-added elements.
details.open = true;
}
});
</script>

<div class="event-box" style={`--highlights: var(--${info.acmPath.slug}-rgb)`}>
<div class="event-box" style={`--highlights: var(--acm-${info.acmPath.slug}-rgb)`}>
<!-- Workaround for the top panel covering the event card's anchor. -->
<div class="anchor" id={info.slug} bind:this={anchor} />
<details class="event-card" bind:this={details}>
Expand Down Expand Up @@ -53,20 +72,23 @@
rel="noopener noreferrer">Join</a>
</summary>
<hr />
<p class="event-description">{info.description}</p>
<p class="event-description">
{@html info.description}
</p>
<div class="event-actionbar">
<button
on:click={() => {
copyEventLink(info);
}}>
<CopyLinkIcon />
</button>
</div>
</details>
</div>

<style lang="scss">
@import 'static/theme.scss';

:root {
--general-rgb: 44, 145, 198;
--algo-rgb: 157, 53, 231;
--create-rgb: 255, 67, 101;
--dev-rgb: 30, 108, 255;
}

.event-box {
position: relative;
}
Expand All @@ -81,29 +103,29 @@
.event-card {
margin: 32px 64px;
padding: 0;
box-shadow: 0 6px 18px rgba(var(--highlights, --general-rgb), 0.25);
box-shadow: 0 6px 18px rgba(var(--highlights, --acm-general-rgb), 0.25);
transition: all 0.15s ease-in-out;
border-radius: 30px;
border: 2px solid var(--acm-dark);
}

.event-card:hover {
box-shadow: 0 6px 18px rgba(var(--highlights, --general-rgb), 0.65);
box-shadow: 0 6px 18px rgba(var(--highlights, --acm-general-rgb), 0.65);
}

.event-card[open] {
box-shadow: 0 6px 24px rgba(var(--highlights, --general-rgb), 0.75);
border: 2px solid rgb(var(--highlights, --general-rgb));
box-shadow: 0 6px 24px rgba(var(--highlights, --acm-general-rgb), 0.75);
border: 2px solid rgb(var(--highlights, --acm-general-rgb));
}

.event-card:hover h2,
.event-card[open] h2 {
color: rgb(var(--highlights, --general-rgb));
color: rgb(var(--highlights, --acm-general-rgb));
}

.event-box > .anchor:target + .event-card {
box-shadow: 0 6px 24px rgba(var(--highlights, --general-rgb), 0.75);
border: 2px solid rgb(var(--highlights, --general-rgb));
box-shadow: 0 6px 24px rgba(var(--highlights, --acm-general-rgb), 0.75);
border: 2px solid rgb(var(--highlights, --acm-general-rgb));
}

.event-card hr {
Expand Down Expand Up @@ -140,7 +162,7 @@
}

.event-body:hover .event-name {
color: rgb(var(--highlights, --general-rgb));
color: rgb(var(--highlights, --acm-general-rgb));
}

.event-body h2 {
Expand Down Expand Up @@ -206,6 +228,29 @@
font-style: italic;
}

.event-actionbar {
display: flex;
flex-direction: row-reverse;
padding: 0 2em 2em 2em;

button {
--size: 40px;

width: var(--size);
height: var(--size);
padding: calc(var(--size) * 0.15);
box-shadow: 0 6px 18px rgba(var(--highlights, --acm-general-rgb), 0.25);
transition: all 0.25s ease-in-out;
border-radius: 30px;
border: 2px solid var(--acm-dark);
background-color: var(--acm-light);
}

button:hover {
box-shadow: 0 6px 18px rgba(var(--highlights, --acm-general-rgb), 0.66);
}
}

@media (max-width: 799px) {
.event-body {
flex-direction: column;
Expand Down
8 changes: 8 additions & 0 deletions src/lib/components/icons/copy-link.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"
><title>Link</title><path
d="M208 352h-64a96 96 0 010-192h64M304 160h64a96 96 0 010 192h-64M163.29 256h187.42"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="36" /></svg>
65 changes: 65 additions & 0 deletions src/lib/components/utils/acm-toaster.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<script lang="ts">
import { fly } from 'svelte/transition';
import { toasts, ToastType } from '$lib/stores/toasts';
</script>

<section>
{#each $toasts as toastItem (toastItem.id)}
<div
class="toast-item"
class:error={toastItem.type === ToastType.Error}
class:success={toastItem.type === ToastType.Success}
class:info={toastItem.type === ToastType.Info}
in:fly={{ y: 20 }}
out:fly={{ y: -20 }}
style={`--highlights: var(--acm-${toastItem.path}-rgb)`}>
<img src="/assets/png/acm-shark.png" alt="acmCSUF Mascot: Frank the Shark" />
<p>{@html toastItem.content}</p>
</div>
{/each}
</section>

<style>
:root {
--success-rgb: 157, 231, 53;
--error-rgb: 255, 67, 101;
--info-rgb: 30, 108, 255;
}

section {
position: fixed;
z-index: 10000;
bottom: 0;
right: 50%;
transform: translateX(50%);
min-width: min(390px, 100%);
}

.toast-item {
display: flex;
flex-direction: row;
gap: 1em;
margin: 32px 32px;
padding: 2em;
transition: all 0.15s ease-in-out;
box-shadow: 0 6px 24px rgba(var(--highlights, --acm-general-rgb), 0.75);
border: 2px solid rgb(var(--highlights, --acm-general-rgb));
transition: all 0.25s ease-in-out;
border-radius: 30px;
background-color: var(--acm-light);
}

.toast-item img {
--img-width: 50px;
width: var(--img-width);
height: calc(var(--img-width) * 0.56);
align-self: center;
}

@media (min-width: 800px) {
section {
right: 0;
transform: translateX(0);
}
}
</style>
35 changes: 22 additions & 13 deletions src/lib/ical/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ export function computeIcalDatetime(event: IcalOutput): Date {
return parseRawIcalDatetime(rawDatetime as string);
}

export function slugifyEvent(summary: string, month: string, day: number): string {
export function slugifyEvent(summary: string, year: string, month: string, day: number): string {
const cleanedSummary = summary.replace(/[^\w\s_]/g, '').replace(/(\s|-|_)+/g, '-');
const slug = [cleanedSummary, month, day].join('-').toLowerCase();
const slug = [cleanedSummary, year, month, day].join('-').toLowerCase();
return slug;
}

Expand All @@ -145,21 +145,30 @@ export interface AcmEventDescription {
variables: Map<string, string>;
}

export function parseDescription(content: string): AcmEventDescription {
const resultingLines = [];
export function parseDescription(content: string, varPrefix = 'ACM_'): AcmEventDescription {
const variables = new Map<string, string>();

for (const line of content.split(/\\n/)) {
if (line.includes('=')) {
const [key, ...value] = line.split('=');
variables.set(key.trim(), value.join('=').trim());
} else {
// Add line to unescaped description.
resultingLines.push(line.replace(/\\/g, ''));
}
let description = content.replace(/\\n/g, '<br>').replace(/\\/g, '');

// Extract variables from the description until there are no more.
while (description.includes(varPrefix)) {
const start = description.indexOf(varPrefix);
const nextTag = description.indexOf('<', start);
const end =
nextTag > -1
? nextTag // Stop at next HTML tag (e.g. '<br>')
: description.length; // Or stop at end of string

const variable = description.substring(start, end);

const splitAt = variable.indexOf('=');
const key = variable.substring(0, splitAt).trim();
const value = variable.substring(splitAt + 1);

variables.set(key, value);
description = description.substring(0, start) + description.substring(end);
}

const description = resultingLines.join('\n');
return { description, variables };
}

Expand Down
10 changes: 6 additions & 4 deletions src/lib/ical/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ export function parse(icalData: string): AcmEvent[] {
: '/discord';

const date = computeIcalDatetime(event);
const year = date.toLocaleString(ACM_LOCALE, { year: 'numeric' });
const month = date.toLocaleString(ACM_LOCALE, { month: 'long' });
const day = date.getDate();
const time = date.toLocaleTimeString(ACM_LOCALE, { hour: 'numeric', minute: 'numeric' });

const slug = slugifyEvent(summary, month, day);
const slug = slugifyEvent(summary, year, month, day);

const recurring = checkForRecurrence(String(event['RRULE']));

Expand All @@ -72,7 +72,7 @@ export function parse(icalData: string): AcmEvent[] {
? acmDev
: acmGeneral;

collection.push({
const item = {
month,
day,
time,
Expand All @@ -84,7 +84,9 @@ export function parse(icalData: string): AcmEvent[] {
slug,
recurring,
acmPath,
});
};

collection.push(item);

return collection;
}, [])
Expand Down
66 changes: 66 additions & 0 deletions src/lib/stores/toasts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { acmGeneral } from '$lib/constants';
import { writable } from 'svelte/store';

const MAX_TOASTS = 4;
const TOAST_TIMEOUT = 2e3;

const numToasts = writable<number>(0);

export enum ToastType {
Success = 'success',
Error = 'error',
Info = 'info',
}

export interface Toast {
id: number;
content: string;
type?: ToastType;
dismissible?: boolean;
timeout?: number;
path?: string;
}

export const toasts = writable<Toast[]>([]);

function makeToast(
id: number,
{ content, type, dismissible, timeout, path }: Omit<Toast, 'id'>
): Required<Toast> {
return {
id,
content: content,
type: type ?? ToastType.Info,
dismissible: dismissible ?? true,
timeout: timeout ?? TOAST_TIMEOUT,
path: path ?? acmGeneral.slug,
};
}

export function toast(props: Omit<Toast, 'id'>): void {
numToasts.update((value: number) => {
const nextToast = makeToast(value + 1, props);

toasts.update((allToasts) => {
postponeDismissal(nextToast.id, nextToast.timeout);

while (allToasts.length > MAX_TOASTS - 1) {
const currId = allToasts[0].id;
dismissToast(currId);
allToasts.shift();
}

return [...allToasts, nextToast];
});

return nextToast.id;
});
}

export function postponeDismissal(id: number, timeout: number): void {
setTimeout(() => dismissToast(id), timeout);
}

export function dismissToast(id: number): void {
toasts.update((all) => all.filter((t) => t.id !== id));
}
Loading