Boostrap project

This commit is contained in:
Kevin Kandlbinder 2023-04-06 17:13:42 +02:00
commit c2500c85e7
Signed by: kevin
GPG key ID: 1460B586646E180D
38 changed files with 4149 additions and 0 deletions

View file

@ -0,0 +1,82 @@
import Secrets from '$lib/server/secrets';
import type { RequestHandler } from './$types';
import GhostContentAPI from '@tryghost/content-api';
import { Feed } from 'feed';
import { DateTime } from 'luxon';
import { error } from '@sveltejs/kit';
export const trailingSlash = 'never';
export const prerender = true;
export const GET: RequestHandler = async (request) => {
//const siteBase = `${request.url.protocol}//${request.url.host}/`
const siteBase = `https://public-spaces-preview.pages.dev/`; // TODO: Change!
const api = new GhostContentAPI({
url: Secrets.ghost.url,
key: Secrets.ghost.key,
version: 'v5.0'
});
const posts = await api.posts.browse({
limit: 100,
include: ['authors', 'tags']
});
const feed = new Feed({
title: 'Public Spaces e.V.',
generator: 'Public Spaces e.V. Web',
description: 'Die neusten Beiträge von Public Spaces e.V.',
language: 'de-DE',
link: siteBase,
feedLinks: {
rss: `${siteBase}posts.rss`,
json: `${siteBase}posts.json`,
atom: `${siteBase}posts.atom`
},
id: siteBase,
copyright: ''
});
posts.forEach((post) => {
const date = DateTime.fromISO(post.updated_at || post.published_at || post.created_at || '');
const datePublished = DateTime.fromISO(post.published_at || post.created_at || '');
feed.addItem({
id: `${siteBase}post/${post.slug}`,
title: post.title || '',
link: `${siteBase}post/${post.slug}`,
date: date.toJSDate(),
published: datePublished.toJSDate(),
description: post.excerpt || '',
content: post.html || undefined,
author: post.authors?.map((author) => {
return {
name: author.name
};
})
});
});
switch (request.params.format) {
case 'rss':
return new Response(feed.rss2(), {
headers: {
'Content-Type': 'application/rss+xml'
}
});
case 'atom':
return new Response(feed.atom1(), {
headers: {
'Content-Type': 'application/atom+xml'
}
});
case 'json':
return new Response(feed.json1(), {
headers: {
'Content-Type': 'application/feed+json'
}
});
}
throw error(404, 'Ungültiges Format');
};

View file

@ -0,0 +1,26 @@
import Secrets from '$lib/server/secrets';
import { error } from '@sveltejs/kit';
import GhostContentAPI from '@tryghost/content-api';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const api = new GhostContentAPI({
url: Secrets.ghost.url,
key: Secrets.ghost.key,
version: 'v5.0'
});
let page = null;
try {
page = await api.pages.read({
slug: params.slug
});
} catch (e) {
throw error(404, 'Seite Nicht Gefunden');
}
return {
page
};
};

View file

@ -0,0 +1,31 @@
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<svelte:head>
<title>{data.page?.title}</title>
</svelte:head>
<div class="container">
<div class="header">
<h1>{data.page?.title}</h1>
</div>
<div class="page">
{@html data.page?.html}
</div>
</div>
<style lang="scss">
.page {
:global {
@include ghostContentContainer;
}
}
.header {
@include contentGrid;
}
</style>

24
src/routes/+error.svelte Normal file
View file

@ -0,0 +1,24 @@
<script lang="ts">
import { page } from '$app/stores';
const texts: { [key: number]: string } = {
0: 'Ein unbekannter Fehler ist aufgetreten',
404: 'Die angeforderte Seite wurde leider nicht gefunden.',
500: 'Es ist ein interner Serverfehler aufgetreten.'
};
</script>
<div class="container">
<h1>{$page.status}: {$page.error?.message}</h1>
<p>
{texts[$page.status] ?? texts[0]}
Bitte versuche es später erneut, oder gehe zurück zur <a href="/">Startseite</a>.
</p>
</div>
<style lang="scss">
.container {
@include contentGrid;
}
</style>

298
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,298 @@
<script lang="ts">
import { siTwitter, siInstagram, siTelegram } from 'simple-icons';
import { Menu, X } from 'lucide-svelte';
import Logo from '../assets/logo/LOGO-public-spaces.svg';
import { getContext } from 'svelte';
import '../app.scss';
import type { LayoutData } from './$types';
export let data: LayoutData;
let menuOpen = false;
</script>
<a href="#navigation" class="sr-only sr-only-focusable a11y-jump">Zur Navigation Springen</a>
<a href="#content" class="sr-only sr-only-focusable a11y-jump">Zum Inhalt Springen</a>
<div class="primary">
<nav id="navigation">
<div>
<div class="logo-container">
<a href="/" class="logo-link" title="Public Spaces e.V.">
<img src={Logo} alt="Public Spaces Logo" style={data.isHome ? "opacity: 0; transform: translate(20%, 50%)":""} />
<span class="sr-only">Public Spaces e.V.</span>
</a>
</div>
<div class={'offscreen-nav' + (menuOpen ? ' active' : '')}>
<a
href="/posts"
on:click={() => {
menuOpen = false;
}}>Beiträge</a
>
<a
href="/about"
on:click={() => {
menuOpen = false;
}}>Über Uns</a
>
<a
href="/contact"
on:click={() => {
menuOpen = false;
}}>Kontakt</a
>
</div>
<div class="offscreen-nav-button">
<button
on:click={() => {
menuOpen = !menuOpen;
}}
>
{#if !menuOpen}
<Menu size={35} />
{/if}
{#if menuOpen}
<X size={35} />
{/if}
</button>
</div>
</div>
</nav>
<div class="main" role="main" id="content">
<slot />
</div>
<footer>
<div class="grid">
<div class="primary-links">
<a href="/imprint">Impressum</a>
<a href="/data_protection">Datenschutz</a>
<a href="/disclaimer">Disclaimer</a>
</div>
<div class="border" />
<div class="socials">
<!--<a
href="https://twitter.com/TODO"
target="_blank"
rel="noreferrer"
title="Twitter"
>
{@html siTwitter.svg}
<span class="sr-only">Twitter @TODO</span>
</a>
<a
href="https://t.me/TODO"
target="_blank"
rel="noreferrer"
title="Telegram"
>
{@html siTelegram.svg}
<span class="sr-only">Telegram @TODO</span>
</a>-->
</div>
<div class="border" />
<div class="supporters">
<span class="title">Unterstützer des Vereins</span>
</div>
</div>
<div class="copy-notice">
CC-BY-4.0 {new Date().getFullYear()}, Public Spaces e.V.
</div>
</footer>
</div>
<style lang="scss">
.primary {
@include contentGrid;
padding-top: var(--nav-space);
.main {
grid-column: full-start/full-end;
min-height: calc(100vh - 200px);
}
footer {
margin-top: 4em;
background-color: var(--color-dark-surface);
color: var(--color-dark-surface-text);
grid-column: full-start/full-end;
@include contentGrid;
.grid {
grid-column: wide-start/wide-end;
display: grid;
grid-template-columns: 1fr 1px 1fr 1px 1fr;
gap: var(--gap);
@media (max-width: 700px) {
grid-template-columns: 1fr;
}
> .border {
width: 1px;
margin: 10px 0;
background-color: currentColor;
opacity: 0.25;
}
> :not(.border) {
padding: var(--gap);
}
.primary-links {
display: flex;
flex-direction: column;
align-items: center;
> a {
color: var(--color-dark-surface-text-dim);
}
}
.supporters {
.title {
color: var(--color-dark-surface-text-dim);
}
}
.socials {
display: flex;
justify-content: center;
a {
padding: var(--padding);
text-decoration: none;
background-color: var(--color-surface);
margin: var(--padding);
border-radius: var(--border-radius);
display: flex;
align-items: center;
justify-content: center;
:global(svg) {
width: 32px;
height: 32px;
fill: currentColor;
}
}
}
}
.copy-notice {
text-align: center;
padding: var(--gap);
}
}
nav {
@include contentGrid;
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 200;
overflow: visible;
@include glassSurface;
> div {
display: flex;
.logo-container {
margin-right: auto;
.logo-placeholder,
img {
transition: opacity .25s, transform .25s;
width: 100px;
height: 45px;
//background-color: var(--color-placeholder);
//border-radius: var(--border-radius);
}
}
.offscreen-nav-button {
width: 0;
pointer-events: none;
opacity: 0;
transition: width 0.25s, opacity 0.25s;
> button {
height: 100%;
padding: 0 var(--gap);
font: inherit;
color: inherit;
border: none;
cursor: pointer;
background-color: transparent;
}
}
@media (max-width: 700px) {
.offscreen-nav-button {
display: block;
z-index: 300;
pointer-events: auto;
width: fit-content;
opacity: 1;
}
.offscreen-nav {
//display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 195;
display: flex;
flex-direction: column;
padding-top: 110px;
font-size: 1.5em;
overflow: hidden;
pointer-events: none;
height: 0;
opacity: 0;
transition: height 0.25s, opacity 0.25s;
@include glassSurface;
&.active {
overflow: auto;
pointer-events: auto;
height: 100vh;
opacity: 1;
}
> a {
display: flex;
justify-content: center;
}
}
}
> div {
display: flex;
> a {
display: flex;
align-items: center;
padding: var(--padding);
text-decoration: none;
&.logo-link {
margin-right: auto;
}
}
}
}
}
}
.a11y-jump:focus,
.a11y-jump:active {
padding: var(--gap) !important;
background-color: var(--color-background);
z-index: 9999;
}
</style>

9
src/routes/+layout.ts Normal file
View file

@ -0,0 +1,9 @@
import type { LayoutLoad } from './$types';
export const load = (async ({url}) => {
return {
isHome: url.pathname == "/"
};
}) satisfies LayoutLoad;
export const prerender = true;

View file

@ -0,0 +1,24 @@
import type { PageServerLoad } from './$types';
import GhostContentAPI from '@tryghost/content-api';
import Secrets from '$lib/server/secrets';
export const load: PageServerLoad = async () => {
const api = new GhostContentAPI({
url: Secrets.ghost.url,
key: Secrets.ghost.key,
version: 'v5.0'
});
const posts = await api.posts.browse({
limit: 3,
include: ['authors', 'tags']
});
const about = await api.pages.read({
slug: 'about'
});
return { posts, about };
};

230
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,230 @@
<script lang="ts">
import type { PageData } from './$types';
import { ChevronRight } from 'lucide-svelte';
import { DateTime } from 'luxon';
import { onDestroy, setContext, onMount, tick } from 'svelte';
import Logo from '../assets/logo/LOGO-public-spaces.svg';
import HWFLogo from '../assets/logo/LOGO-hamburg-werbefrei.svg';
import BWFLogo from '../assets/logo/LOGO-berlin-werbefrei.svg';
export let data: PageData;
</script>
<svelte:head>
<title>Public Spaces e.V.</title>
<meta name="title" content={'Public Spaces e.V.'} />
<meta
name="description"
content={'Public Spaces ist Trägerin zweier Voksinitiativen, die sich gegen ein Übermaß von Werbung im öffentlichen Raum richten.'}
/>
</svelte:head>
<div class="home-runner">
<div class="hero">
<div class="hero-runner">
<img src={Logo} alt="Public Spaces Logo" />
<div class="sub-logo-split">
<img src={BWFLogo} alt="Berlin Werbefrei Logo" />
<img src={HWFLogo} alt="Hamburg Werbefrei Logo" />
</div>
</div>
</div>
<h2>Über Public Spaces e.V.</h2>
<p>
Hier entsteht die Webseite des Vereins Public Spaces e.V.
</p>
<p>
Public Spaces ist Trägerin zweier Volksinitiativen, die sich gegen ein Übermaß von Werbung im öffentlichen Raum richten.
</p>
<a href="/about" class="more-cta">Mehr über uns <ChevronRight /></a>
<hr />
<h2>Neuste Beiträge</h2>
<div class="posts">
{#each data.posts as post}
<a href={`/posts/${post.slug}`}>
{#if !post.feature_image}
<div class="image-placeholder">?</div>
{/if}
{#if post.feature_image}
<img
src={post.feature_image}
alt={post.feature_image_alt || post.feature_image_caption || 'Article'}
/>
{/if}
<span class="title">{post.title}</span>
<p>{post.excerpt}</p>
</a>
{/each}
</div>
<a href="/posts" class="kg-btn">Alle Beiträge sehen</a>
<!--<a href="/about" class="more-cta">Alle Beiträge <ChevronRight/></a>-->
</div>
<style lang="scss">
.home-runner {
@include contentGrid;
margin-top: calc(0px - var(--nav-space));
}
.kg-btn {
margin: 20px auto;
}
.more-cta {
margin: var(--gap) var(--gap) var(--gap) auto;
display: flex;
justify-content: center;
align-items: center;
gap: var(--padding);
font-size: 1.2em;
text-decoration: none;
}
.posts {
grid-column: wide-start/wide-end;
display: flex;
gap: var(--gap);
margin-top: 1em;
> * {
flex-basis: 0;
flex-grow: 1;
width: 100px;
}
@media (max-width: 800px) {
flex-direction: column;
> * {
width: 100%;
}
}
> a {
text-decoration: none;
display: flex;
flex-direction: column;
img,
.image-placeholder {
width: 100%;
height: 230px;
background-color: var(--color-dark-surface);
border-radius: var(--border-radius);
}
.image-placeholder {
display: flex;
justify-content: center;
align-items: center;
font-size: 3em;
font-weight: 600;
color: grey;
}
.title {
font-size: 1.75em;
margin-top: 0.8em;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 40px;
}
p {
display: -webkit-box;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
color: black;
}
}
}
.hero {
grid-column: full-start/full-end;
max-height: max(70vmin, 500px);
min-height: 100px;
height: 40vmax;
position: relative;
.hero-runner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding-top: 50px;
z-index: 2;
display: flex;
flex-direction: column;
font-weight: 800;
text-transform: uppercase;
color: white;
align-items: center;
overflow: hidden;
> img {
flex-grow: 1;
flex-basis: 0;
object-fit: contain;
object-position: center;
max-height: 100%;
max-width: 100%;
height: 90%;
opacity: 1;
transform: translate(0, 0);
animation-name: homeLogoIn;
animation-duration: .25s;
}
.sub-logo-split {
margin-top: 20px;
height: 30%;
width: 100%;
margin: 0 auto;
max-width: var(--layout-width);
display: flex;
gap: var(--gap);
justify-content: space-around;
> img {
height: 100%;
object-fit: contain;
}
@media(max-width: 350px) {
flex-direction: column;
height: 50%;
}
}
}
}
@keyframes homeLogoIn {
0% {transform: translate(-10%, -25%); opacity: 0}
100% {transform: translate(0, 0); opacity: 1}
}
</style>

View file

@ -0,0 +1,18 @@
import type { PageServerLoad } from './$types';
import GhostContentAPI from '@tryghost/content-api';
import Secrets from '$lib/server/secrets';
export const load: PageServerLoad = async () => {
const api = new GhostContentAPI({
url: Secrets.ghost.url,
key: Secrets.ghost.key,
version: 'v5.0'
});
const posts = await api.posts.browse({
limit: 100,
include: ['authors', 'tags']
});
return { posts };
};

View file

@ -0,0 +1,127 @@
<script lang="ts">
import type { PageData } from './$types';
import { DateTime } from 'luxon';
export let data: PageData;
</script>
<svelte:head>
<title>Beiträge | Public Spaces e.V.</title>
<meta name="title" content={'Beiträge'} />
<meta
name="description"
content={'Lies die neusten Beiträge von Public Spaces e.V. Hier findest du Pressemitteilungen, Artikel und andere Inhalte.'}
/>
</svelte:head>
<div class="container">
<h1>Beiträge</h1>
<div class="posts">
{#each data.posts as post}
<a href={`/posts/` + post.slug}>
<div class="feature-image">
{#if post.feature_image}
<img
src={post.feature_image}
alt={post.feature_image_alt ||
post.feature_image_caption ||
post.title ||
'Feature Image'}
/>
{/if}
</div>
<div class="details">
<span class="title">{post.title}</span>
<span class="meta">
{#each post.authors || [] as author, i}
{i > 0 ? ' & ' : ''}<span>{author.name}</span>
{/each} &middot;
<span
>{DateTime.fromISO(post.published_at || post.created_at || '2001-11-03')
.setLocale('de-DE')
.toLocaleString(DateTime.DATE_FULL)}</span
>
&middot;
<span
>Ca. {post.reading_time || post.reading_time === 0
? Math.max(1, post.reading_time) + ' Min.'
: 'Unbekannte'} Lesezeit</span
>
</span>
{#if post.custom_excerpt || post.excerpt}
<p class="excerpt">
{(post.custom_excerpt || post.excerpt)?.replaceAll('\n', ' ')}
</p>
{/if}
</div>
</a>
{/each}
</div>
</div>
<style lang="scss">
.container {
@include contentGrid;
}
.posts {
> a {
display: flex;
gap: var(--gap);
padding: var(--gap) 0;
color: inherit;
text-decoration: none;
position: relative;
&::after {
position: absolute;
content: '';
display: block;
width: 90%;
margin: 0 auto;
bottom: 0;
left: 50%;
transform: translate(-50%, 0);
border-bottom: thin solid var(--color-border);
}
> .feature-image {
--size: 150px;
width: var(--size);
height: var(--size);
flex-shrink: 0;
> img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
}
> .details {
display: flex;
flex-direction: column;
> .title {
font-size: 1.6em;
font-weight: 600;
color: var(--color-accent);
}
> .meta {
opacity: 0.75;
margin-top: 5px;
font-weight: 300;
}
> .excerpt {
margin-bottom: 0;
}
}
}
}
</style>

View file

@ -0,0 +1,29 @@
import type { PageServerLoad } from './$types';
import GhostContentAPI from '@tryghost/content-api';
import Secrets from '$lib/server/secrets';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
const api = new GhostContentAPI({
url: Secrets.ghost.url,
key: Secrets.ghost.key,
version: 'v5.0'
});
let post = null;
try {
post = await api.posts.read(
{
slug: params.slug
},
{ include: ['tags', 'authors'] }
);
} catch (e) {
throw error(404, 'Artikel Nicht Gefunden');
}
return {
post
};
};

View file

@ -0,0 +1,216 @@
<script lang="ts">
import type { PageData } from './$types';
import { DateTime } from 'luxon';
import { Globe } from 'lucide-svelte';
import { siTwitter } from 'simple-icons';
export let data: PageData;
$: publishTime = DateTime.fromISO(data.post.published_at || data.post.created_at || '2001-11-03');
</script>
<svelte:head>
<title>{data.post.title}</title>
<meta name="title" content={data.post.meta_title || data.post.title} />
<meta
name="description"
content={data.post.meta_description || data.post.custom_excerpt || data.post.excerpt}
/>
<meta name="twitter:card" content="summary" />
<meta name="twitter:site" content="@todo" /> <!-- TODO: Change me. -->
<meta
name="twitter:title"
content={data.post.twitter_title || data.post.meta_title || data.post.title}
/>
<meta
name="twitter:description"
content={data.post.twitter_description ||
data.post.meta_description ||
data.post.custom_excerpt ||
data.post.excerpt}
/>
<meta name="twitter:image" content={data.post.twitter_image || data.post.feature_image} />
<meta property="og:type" content="article" />
<meta
property="og:title"
content={data.post.og_title || data.post.meta_title || data.post.title}
/>
<meta
property="og:description"
content={data.post.og_description ||
data.post.meta_description ||
data.post.custom_excerpt ||
data.post.excerpt}
/>
<meta
property="og:image"
content={data.post.og_image || data.post.twitter_image || data.post.feature_image}
/>
<meta property="article:published_time" content={data.post.published_at} />
{#if data.post.updated_at}
<meta property="article:modified_time" content={data.post.updated_at} />
{/if}
{#if data.post.primary_tag}
<meta
property="article:section"
content={data.post.primary_tag.og_title || data.post.primary_tag.name}
/>
{/if}
{@html `<script type="application/ld+json">${JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: data.post.title,
image: data.post.feature_image,
datePublished: data.post.published_at,
dateModified: data.post.updated_at,
author: data.post.authors?.map((author) => {
return {
'@type': 'Person',
name: author.name
};
})
})}</script>`}
</svelte:head>
<div class="container">
<div class="header">
<h1>{data.post?.title}</h1>
<span class="meta">
<span>{publishTime.setLocale('de-DE').toLocaleString(DateTime.DATE_FULL)}</span> &middot;
<span
>Ca. {data.post.reading_time || data.post.reading_time === 0
? Math.max(1, data.post.reading_time) + ' Min.'
: 'Unbekannte'} Lesezeit</span
>
&middot;
<span>
{#each data.post.tags || [] as tag, i}
{i > 0 ? ', ' : ''}<span>{tag.name}</span><!--href={`/posts/` + tag.slug}-->
{/each}
</span>
</span>
{#if data.post.feature_image}
<div class="feature-image">
<figure>
<img src={data.post.feature_image} alt={data.post.feature_image_alt || 'Feature Image'} />
<figcaption>{@html data.post.feature_image_caption||""}</figcaption>
</figure>
</div>
{/if}
</div>
<div class="article">
{@html data.post?.html}
<!--<pre>{JSON.stringify(data.post, null, 2)}</pre>-->
</div>
<div class="authors">
<hr />
{#each data.post.authors || [] as author}
<div class="author">
<div class="pic">
{#if author.profile_image}
<img src={author.profile_image} alt={'Profilbild von ' + author.name} />
{/if}
{#if !author.profile_image}
<div class="photo-placeholder" />
{/if}
</div>
<div class="meta">
<span class="name">{author.name}</span>
<p>{author.bio || ''}</p>
<div class="links">
{#if author.website}
<a href={author.website}><Globe /> {new URL(author.website).host}</a>
{/if}
{#if author.twitter}
<a href={'https://twitter.com/' + author.twitter} class="twitter"
>{@html siTwitter.svg} {author.twitter}</a
>
{/if}
</div>
</div>
</div>
{/each}
</div>
</div>
<style lang="scss">
.article {
:global {
@include ghostContentContainer;
}
}
.authors {
@include contentGrid;
.author {
display: flex;
gap: var(--gap);
margin-bottom: var(--gap);
> * {
flex-grow: 0;
flex-shrink: 0;
}
.pic {
width: 125px;
img,
.photo-placeholder {
width: 125px;
height: 125px;
object-fit: cover;
object-position: center;
background-color: var(--color-dark-surface);
}
}
.meta {
width: 0;
flex-grow: 1;
padding: var(--padding);
.name {
font-size: 2em;
font-weight: 700;
}
p {
width: 100%;
}
.links {
display: flex;
gap: var(--gap);
a {
display: flex;
justify-content: center;
align-items: center;
:global(svg) {
width: 25px;
height: 25px;
margin-right: var(--padding);
}
&.twitter {
:global(svg) {
fill: currentColor;
}
}
}
}
}
}
}
.header {
@include articleHeader;
}
</style>