Skip to content

Commit

Permalink
Added action that copies event link to clipboard (#311)
Browse files Browse the repository at this point in the history
* set up logs to test new description parser

* Resolved bug in #309

* Started making progress on fix/281

* Added sliding effect and clipboard interaction

* Move copy to clipboard button to right side

* Update clipboard.svelte

* Renamed icon to CopyLinkIcon

* Made toast animation one dimensional

* Added highlight effects to toasts per path

* Fixed lint error, unused import
  • Loading branch information
EthanThatOneKid committed Mar 3, 2022
1 parent c7e6310 commit 2969cd8
Show file tree
Hide file tree
Showing 9 changed files with 240 additions and 36 deletions.
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

1 comment on commit 2969cd8

@vercel
Copy link

@vercel vercel bot commented on 2969cd8 Mar 3, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.