Redesign the Council cards and popups
This commit is contained in:
@@ -16,17 +16,16 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative size-24 overflow-hidden rounded-full shadow-md ring-2 ring-ecsess-500/20 transition group-hover:ring-ecsess-400/40 sm:size-28 md:size-32 lg:size-36">
|
||||
<div
|
||||
class="bg-ecsess-600/50 relative aspect-square size-20 overflow-hidden rounded-full sm:size-24"
|
||||
>
|
||||
{#if src && !imageError}
|
||||
<img
|
||||
{src}
|
||||
alt={name}
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
onerror={handleImageError}
|
||||
/>
|
||||
{:else if !src || imageError}
|
||||
<div class="bg-ecsess-400 flex h-full w-full items-center justify-center rounded-full">
|
||||
<span class="text-ecsess-black text-xl font-bold"> {getInitials(name)} </span>
|
||||
<img {src} alt={name} class="h-full w-full object-cover" onerror={handleImageError} />
|
||||
{:else}
|
||||
<div
|
||||
class="bg-ecsess-500 text-ecsess-950 flex h-full w-full items-center justify-center text-lg font-bold"
|
||||
>
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
<script>
|
||||
let { onViewProfile, name, position, image } = $props();
|
||||
import Button from 'components/Button.svelte';
|
||||
import CouncilAvatar from 'components/council/CouncilAvatar.svelte';
|
||||
<script lang="ts">
|
||||
let { onViewProfile, name, position, image, featured = false, tag = null } = $props();
|
||||
import { getInitials } from '$lib/format';
|
||||
|
||||
let imageError = $state(false);
|
||||
|
||||
function handleImageError() {
|
||||
imageError = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class="group flex w-full min-w-0 items-center gap-5 rounded-xl border border-ecsess-600/20 bg-ecsess-800/40 p-5 text-ecsess-100 shadow-lg transition hover:border-ecsess-500/30 hover:bg-ecsess-800/60 hover:shadow-xl focus-within:ring-2 focus-within:ring-ecsess-400/50 sm:gap-6 sm:p-6 md:p-6 lg:gap-8 lg:p-8"
|
||||
class="group grid h-full min-w-0 grid-rows-[auto_1fr] overflow-hidden rounded-xl bg-ecsess-800 shadow-md ring-2 ring-ecsess-500/50 ring-offset-2 ring-offset-ecsess-900 transition-all duration-500 ease-out hover:ring-ecsess-300 hover:shadow-xl hover:shadow-ecsess-400/50 focus-within:ring-2 focus-within:ring-ecsess-300 focus-within:ring-offset-2 focus-within:ring-offset-ecsess-900 {featured
|
||||
? 'w-full max-w-[14rem] sm:max-w-[16rem]'
|
||||
: 'w-full max-w-[14rem] sm:max-w-[16rem]'}"
|
||||
>
|
||||
<div class="shrink-0">
|
||||
<CouncilAvatar {name} src={image} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<h3 class="text-lg font-bold leading-tight sm:text-xl md:text-2xl lg:text-3xl">{name}</h3>
|
||||
<p class="text-ecsess-200 mt-1 text-sm italic sm:text-base lg:text-lg">{position}</p>
|
||||
<div class="mt-4 lg:mt-5">
|
||||
<Button onclick={onViewProfile}>View Profile</Button>
|
||||
<div
|
||||
class="relative flex aspect-square w-full min-h-0 items-center justify-center overflow-hidden bg-ecsess-700"
|
||||
>
|
||||
{#if tag}
|
||||
<span
|
||||
class="absolute right-2 top-2 z-[1] rounded-md bg-ecsess-600 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-ecsess-50 shadow-md ring-1 ring-ecsess-400/60"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
{/if}
|
||||
{#if image && !imageError}
|
||||
<img
|
||||
src={image}
|
||||
alt={name}
|
||||
class="h-full w-full object-cover object-center"
|
||||
onerror={handleImageError}
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-4xl font-bold text-ecsess-150 sm:text-5xl" aria-hidden="true">
|
||||
{getInitials(name)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col items-center justify-center gap-2 p-3 text-center sm:p-4">
|
||||
<h3 class="w-full text-base font-bold leading-tight text-ecsess-50 line-clamp-2 sm:text-lg">
|
||||
{name}
|
||||
</h3>
|
||||
<p class="w-full text-xs italic text-ecsess-200 line-clamp-2 sm:text-sm">{position}</p>
|
||||
<button
|
||||
type="button"
|
||||
onclick={onViewProfile}
|
||||
class="mt-1 w-full rounded-lg bg-ecsess-500 px-3 py-2 text-xs font-semibold text-ecsess-950 shadow-md transition hover:bg-ecsess-400 hover:text-ecsess-50 focus:outline-none focus:ring-2 focus:ring-ecsess-300 focus:ring-offset-2 focus:ring-offset-ecsess-800 sm:text-sm"
|
||||
>
|
||||
View profile
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
let {
|
||||
name,
|
||||
position,
|
||||
@@ -9,64 +9,79 @@
|
||||
onClose,
|
||||
id = 'popup-title'
|
||||
} = $props();
|
||||
import { getInitials } from '$lib/format';
|
||||
import { Mail, X } from '@lucide/svelte';
|
||||
import placeholder from 'assets/placeholderAvatar.png';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { scale } from 'svelte/transition';
|
||||
|
||||
let imageError = $state(false);
|
||||
|
||||
function handleImageError() {
|
||||
imageError = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-ecsess-600/30 bg-gradient-to-br from-ecsess-50 to-ecsess-200/90 shadow-2xl shadow-black/30 text-ecsess-800"
|
||||
transition:slide
|
||||
class="relative flex w-full max-w-xl flex-col overflow-hidden rounded-2xl border border-ecsess-650/70 bg-ecsess-900 shadow-2xl text-ecsess-100 md:max-w-2xl md:flex-row"
|
||||
transition:scale
|
||||
role="article"
|
||||
>
|
||||
<!-- Close button: visible and tappable on all screens -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="absolute right-3 top-3 z-10 flex size-10 items-center justify-center rounded-full bg-ecsess-800/90 text-ecsess-100 shadow-lg transition hover:bg-ecsess-700 focus:outline-none focus:ring-2 focus:ring-ecsess-400 focus:ring-offset-2"
|
||||
aria-label="Close profile"
|
||||
class="absolute right-3 top-3 z-10 flex size-8 items-center justify-center rounded-full bg-ecsess-650 text-ecsess-50 transition hover:bg-ecsess-550 focus:outline-none focus:ring-2 focus:ring-ecsess-500 focus:ring-offset-2 focus:ring-offset-ecsess-900 md:size-9"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X class="size-5" />
|
||||
<X class="size-4 md:size-5" />
|
||||
</button>
|
||||
|
||||
<div class="flex flex-1 flex-col overflow-y-auto sm:flex-row">
|
||||
<!-- Avatar block -->
|
||||
<!-- Photo block: top on small, left on md+ -->
|
||||
<div
|
||||
class="flex shrink-0 flex-col items-center gap-2 border-b border-ecsess-300/50 bg-ecsess-100/50 p-6 sm:border-b-0 sm:border-r sm:min-w-[180px]"
|
||||
class="flex shrink-0 flex-col items-center justify-center border-b border-ecsess-700/60 bg-ecsess-850 p-4 md:min-w-[11rem] md:border-b-0 md:border-r md:border-ecsess-700/60 md:p-6"
|
||||
>
|
||||
<div class="size-28 overflow-hidden rounded-xl shadow-md sm:size-32">
|
||||
<div
|
||||
class="flex w-24 aspect-[4/5] items-center justify-center overflow-hidden rounded-lg bg-ecsess-750 shadow-inner ring-1 ring-ecsess-650/60 md:w-40 md:rounded-xl"
|
||||
>
|
||||
{#if image && !imageError}
|
||||
<img
|
||||
src={image || placeholder}
|
||||
src={image}
|
||||
alt={name}
|
||||
class="h-full w-full object-cover"
|
||||
class="h-full w-full object-cover object-center"
|
||||
onerror={handleImageError}
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-xl font-bold text-ecsess-200 md:text-3xl" aria-hidden="true">
|
||||
{getInitials(name)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="text-center text-sm font-medium text-ecsess-700">{yearProgram}</span>
|
||||
{#if yearProgram}
|
||||
<p class="mt-2 text-center text-xs font-semibold uppercase tracking-wider text-ecsess-200">
|
||||
{yearProgram}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content block: more space for long text -->
|
||||
<div class="flex min-w-0 flex-1 flex-col p-6 pt-5 sm:p-8 sm:pt-6">
|
||||
<div class="mb-4">
|
||||
<h2 {id} class="text-xl font-bold leading-tight sm:text-2xl lg:text-3xl">{name}</h2>
|
||||
<p class="mt-0.5 text-base italic text-ecsess-700 sm:text-lg">{position}</p>
|
||||
</div>
|
||||
<!-- Information: below photo on small, right on md+ -->
|
||||
<div class="flex min-w-0 flex-1 flex-col justify-center p-4 md:p-6">
|
||||
<h2 {id} class="text-lg font-bold leading-tight text-ecsess-50 md:text-2xl">{name}</h2>
|
||||
<p class="-mt-0.5 text-sm italic text-ecsess-200 md:text-base">{position}</p>
|
||||
|
||||
<hr class="border-ecsess-400/60 my-3 border-t border-dashed" />
|
||||
|
||||
<div class="space-y-4 text-left">
|
||||
{#if positionDescription}
|
||||
<div class="max-w-prose">
|
||||
<p class="text-sm leading-relaxed text-ecsess-800 sm:text-base">
|
||||
<div class="mt-3 border-t border-ecsess-700/60 pt-3 md:mt-4 md:pt-4">
|
||||
<p class="text-sm leading-relaxed text-ecsess-100 md:text-base">
|
||||
{positionDescription}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if email}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Mail class="size-4 shrink-0 text-ecsess-600" aria-hidden="true" />
|
||||
<div
|
||||
class="mt-3 flex flex-row items-center justify-center gap-2 border-t border-ecsess-700/60 pt-3 text-center md:mt-4 md:pt-4"
|
||||
>
|
||||
<Mail class="size-4 shrink-0 text-ecsess-300" aria-hidden="true" />
|
||||
<a
|
||||
href="mailto:{email}"
|
||||
class="break-all text-sm font-medium text-ecsess-700 underline decoration-ecsess-500 underline-offset-2 transition hover:text-ecsess-800 hover:decoration-ecsess-600 sm:text-base"
|
||||
class="break-all text-sm font-medium text-ecsess-200 underline decoration-ecsess-500 underline-offset-2 hover:text-ecsess-50 hover:decoration-ecsess-400 md:text-base"
|
||||
>
|
||||
{email}
|
||||
</a>
|
||||
@@ -74,5 +89,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,12 +17,11 @@
|
||||
|
||||
const base =
|
||||
'mx-auto flex min-h-[90vh] flex-col items-center gap-4 p-4 text-center text-ecsess-100';
|
||||
const justifyClass = contentStart ? 'justify-start' : 'justify-center';
|
||||
|
||||
// Compute classes: prefer gradient when from/to provided; otherwise fallback to previous behavior
|
||||
let tailwindClasses = $state(base);
|
||||
|
||||
$effect(() => {
|
||||
const justifyClass = contentStart ? 'justify-start' : 'justify-center';
|
||||
const withJustify = `${base} ${justifyClass}`;
|
||||
if (from && to) {
|
||||
tailwindClasses = `${withJustify} bg-gradient-${direction} ${from} ${to} ${via}`;
|
||||
|
||||
13
src/lib/format.ts
Normal file
13
src/lib/format.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Get initials from a name (e.g. "Jane Doe" → "JD", "Mary Jane Watson" → "MJW").
|
||||
* Uses first letter of up to 3 words. Safe for null/undefined/empty.
|
||||
*/
|
||||
export function getInitials(name: string | null | undefined): string {
|
||||
if (name == null || typeof name !== 'string') return '';
|
||||
const words = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return '';
|
||||
return words
|
||||
.slice(0, 3)
|
||||
.map((w) => w.charAt(0).toUpperCase())
|
||||
.join('');
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const councilQuery = `{
|
||||
email,
|
||||
position,
|
||||
positionDescription,
|
||||
"image": image.asset->url+"?h=300&fm=webp",
|
||||
"image": image.asset->url+"?h=360&fm=webp",
|
||||
yearProgram
|
||||
},
|
||||
"councilGoofyPic": *[_type == "homepage"]{
|
||||
@@ -18,7 +18,7 @@ const councilQuery = `{
|
||||
}[0]
|
||||
}`;
|
||||
|
||||
export const load = async ({ url }) => {
|
||||
export const load = async ({ url }: { url: URL }) => {
|
||||
const {
|
||||
members,
|
||||
councilGoofyPic
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import CouncilCardPopUp from 'components/council/CouncilCardPopUp.svelte';
|
||||
import Section from 'components/layout/Section.svelte';
|
||||
import CardCouncil from 'components/council/CouncilCard.svelte';
|
||||
import Link from 'components/Link.svelte';
|
||||
import type { CouncilMember } from '$lib/schemas';
|
||||
import { fly } from 'svelte/transition';
|
||||
import SeoMetaTags from 'components/layout/SeoMetaTags.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { tick } from 'svelte';
|
||||
@@ -12,7 +12,6 @@
|
||||
|
||||
const years = ['U4', 'U3', 'U2', 'U1', 'U0'];
|
||||
|
||||
// Get members by 3 main categories (reactive from data)
|
||||
let president = $derived(
|
||||
data.members.filter((member: CouncilMember) => member.position.includes('President'))[0]
|
||||
);
|
||||
@@ -48,6 +47,11 @@
|
||||
if (e.key === 'Escape') closeModal();
|
||||
}
|
||||
|
||||
function getYearFromPosition(position: string): string | undefined {
|
||||
const match = position.match(/\b(U[0-4])\b/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeModal();
|
||||
@@ -75,82 +79,126 @@
|
||||
canonical={data.canonical}
|
||||
/>
|
||||
|
||||
<Section from="from-ecsess-black" to="to-ecsess-black" via="via-ecsess-800" direction="to-b" contentStart>
|
||||
<div class="flex w-full max-w-4xl flex-col items-center gap-6 py-6 text-left lg:max-w-6xl">
|
||||
<p class="page-title text-balance">Meet the council!</p>
|
||||
<Section
|
||||
from="from-ecsess-black"
|
||||
to="to-ecsess-black"
|
||||
via="via-ecsess-800"
|
||||
direction="to-b"
|
||||
contentStart
|
||||
>
|
||||
<div class="w-full max-w-[90rem] px-4">
|
||||
<!-- Hero -->
|
||||
<h1 class="page-title text-ecsess-50">Meet the ECSESS Council</h1>
|
||||
|
||||
<figure class="mb-20 overflow-hidden rounded-2xl shadow-2xl ring-2 ring-ecsess-400/50">
|
||||
<img
|
||||
src={data.councilGoofyPic.url}
|
||||
alt="ECSESS Council, but we are goofy"
|
||||
class="mb-8 rounded-xl shadow-2xl ring-2 ring-ecsess-400/50 shadow-black/40 transition-all hover:ring-ecsess-300/60 hover:shadow-ecsess-900/30 lg:w-[90%]"
|
||||
transition:fly
|
||||
alt="ECSESS Council having fun"
|
||||
class="w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 class="border-b-ecsess-300/80 w-full max-w-6xl border-b-2 pb-3 text-center font-semibold tracking-tight">
|
||||
Our Student Council
|
||||
</h1>
|
||||
</figure>
|
||||
|
||||
<!-- President -->
|
||||
{#if president}
|
||||
<div class="w-full max-w-2xl lg:max-w-3xl">
|
||||
<section class="mb-12 w-full">
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<div class="h-0.5 flex-1 bg-ecsess-300" aria-hidden="true"></div>
|
||||
<h2 class="shrink-0 text-xl font-semibold uppercase tracking-wider text-ecsess-100">
|
||||
President
|
||||
</h2>
|
||||
<div class="h-0.5 flex-1 bg-ecsess-300" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center">
|
||||
<CardCouncil
|
||||
name={president.name}
|
||||
position={president.position}
|
||||
image={president.image}
|
||||
onViewProfile={() => handleViewProfile(president)}
|
||||
featured
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<div class="grid w-full max-w-6xl place-items-center gap-6">
|
||||
<h2
|
||||
class="border-b-ecsess-300/70 w-full border-b-2 border-dashed pb-2 text-center text-lg font-semibold tracking-tight"
|
||||
>
|
||||
<!-- Vice Presidents -->
|
||||
<section class="mb-20 w-full">
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<div class="h-0.5 flex-1 bg-ecsess-300" aria-hidden="true"></div>
|
||||
<h2 class="shrink-0 text-2xl font-semibold uppercase tracking-wider text-ecsess-100">
|
||||
Vice Presidents
|
||||
</h2>
|
||||
<div
|
||||
class="grid w-full grid-cols-1 gap-5 py-4 sm:gap-6 md:grid-cols-2 md:gap-6 lg:gap-8 xl:gap-10"
|
||||
>
|
||||
<div class="h-0.5 flex-1 bg-ecsess-300" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
{#each vps as vp}
|
||||
<div
|
||||
class="flex min-w-full justify-center sm:min-w-[calc(50%-0.5rem)] lg:min-w-[calc(33.333%-0.67rem)] xl:min-w-[calc(25%-0.75rem)]"
|
||||
>
|
||||
<CardCouncil
|
||||
name={vp.name}
|
||||
position={vp.position}
|
||||
image={vp.image}
|
||||
onViewProfile={() => handleViewProfile(vp)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<h2
|
||||
class="border-b-ecsess-300/70 w-full border-b-2 border-dashed pb-2 text-center text-lg font-semibold tracking-tight"
|
||||
>
|
||||
</section>
|
||||
|
||||
<!-- Year Representatives -->
|
||||
<section class="mb-20 w-full">
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<div class="h-0.5 flex-1 bg-ecsess-300" aria-hidden="true"></div>
|
||||
<h2 class="shrink-0 text-2xl font-semibold uppercase tracking-wider text-ecsess-100">
|
||||
Year Representatives
|
||||
</h2>
|
||||
<div
|
||||
class="grid w-full grid-cols-1 gap-5 py-4 sm:gap-6 md:grid-cols-2 md:gap-6 lg:gap-8 xl:gap-10"
|
||||
>
|
||||
<div class="h-0.5 flex-1 bg-ecsess-300" aria-hidden="true"></div>
|
||||
</div>
|
||||
<div class="mt-8 flex flex-wrap justify-center gap-4">
|
||||
{#each ureps as urep}
|
||||
<div
|
||||
class="flex min-w-full justify-center sm:min-w-[calc(50%-0.5rem)] lg:min-w-[calc(33.333%-0.67rem)] xl:min-w-[calc(25%-0.75rem)]"
|
||||
>
|
||||
<CardCouncil
|
||||
name={urep.name}
|
||||
position={urep.position}
|
||||
image={urep.image}
|
||||
onViewProfile={() => handleViewProfile(urep)}
|
||||
tag={getYearFromPosition(urep.position)}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Join CTA at bottom -->
|
||||
<section class="mt-4 mb-12 text-center">
|
||||
<p class="text-lg text-ecsess-200 sm:text-xl">
|
||||
Like what you see?
|
||||
<Link
|
||||
href="/join"
|
||||
class="font-semibold text-ecsess-50 underline decoration-ecsess-300 underline-offset-4 transition hover:text-ecsess-100 hover:decoration-ecsess-200"
|
||||
>
|
||||
Join ECSESS and be part of the fun →
|
||||
</Link>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if selectedMember}
|
||||
<!-- Modal: backdrop with blur, click-outside and Escape to close -->
|
||||
<div
|
||||
bind:this={modalRef}
|
||||
tabindex="-1"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm outline-none overflow-y-auto"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto bg-black/70 p-4 backdrop-blur-sm outline-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="popup-title"
|
||||
onclick={(e) => e.target === e.currentTarget && closeModal()}
|
||||
onkeydown={onBackdropKeydown}
|
||||
>
|
||||
<div class="relative flex max-h-[90vh] w-full max-w-4xl flex-col items-center justify-center overflow-hidden">
|
||||
<div
|
||||
class="relative my-auto flex w-full max-w-2xl flex-col items-center px-2 md:max-w-3xl md:px-4"
|
||||
>
|
||||
<CouncilCardPopUp
|
||||
id="popup-title"
|
||||
name={selectedMember.name}
|
||||
|
||||
Reference in New Issue
Block a user