mixc
This commit is contained in:
1 parent
00edb9dfc2
commit
3865c45642
18 files changed
+439
-87
No files matched your search
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import { feedType, feedTypes, getFeed } from '../data/insights';
|
||||
import { Picture } from 'astro:assets';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { formatDate, localePath, t, type Lang } from '../i18n/ui';
|
||||
@@ -7,7 +7,16 @@ import { formatDate, localePath, t, type Lang } from '../i18n/ui';
|
||||
interface Props { lang: Lang }
|
||||
const { lang } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const posts = (await getCollection('insights')).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
const feed = await getFeed();
|
||||
const m = ui.insights.mention;
|
||||
const f = ui.insights.filter;
|
||||
/* Only filters that have entries; "All" first. */
|
||||
const filters = [
|
||||
{ key: 'all', label: f.all, count: feed.length },
|
||||
...feedTypes
|
||||
.map((key) => ({ key, label: f[key], count: feed.filter((item) => feedType(item) === key).length }))
|
||||
.filter((x) => x.count > 0),
|
||||
];
|
||||
---
|
||||
<Base lang={lang} title={ui.meta.insights.title} description={ui.meta.insights.description}>
|
||||
<section class="section section--intro">
|
||||
@@ -19,16 +28,29 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
|
||||
|
||||
<section class="section--list" aria-label={ui.insights.title}>
|
||||
<div class="container">
|
||||
<ul class="posts" role="list">
|
||||
{posts.map((post) => (
|
||||
<li class="post">
|
||||
<a class="post__link" href={localePath(lang, `/insights/${post.id}`)} lang={post.data.lang !== lang ? post.data.lang : undefined}>
|
||||
{filters.length > 2 && (
|
||||
<fieldset class="choice-group filters" data-filters>
|
||||
<legend class="choice-group__legend">{f.label}</legend>
|
||||
<div class="choices">
|
||||
{filters.map((x) => (
|
||||
<label class="choice">
|
||||
<input type="radio" name="insights-type" value={x.key} checked={x.key === 'all'} />
|
||||
<span>{x.label} <span class="choice__n">{x.count}</span></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
<ul class="posts" role="list" data-feed>
|
||||
{feed.map((item) => item.kind === 'post' ? (
|
||||
<li class="post" data-type={feedType(item)}>
|
||||
<a class="post__link" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
|
||||
<div class="post__media">
|
||||
<Picture
|
||||
src={post.data.cover}
|
||||
alt={post.data.coverAlt}
|
||||
src={item.entry.data.cover}
|
||||
alt={item.entry.data.coverAlt}
|
||||
formats={['avif']}
|
||||
fallbackFormat="webp"
|
||||
fallbackFormat="webp"
|
||||
widths={[400, 640, 960]}
|
||||
sizes="(min-width: 48rem) 40vw, 100vw"
|
||||
loading="lazy"
|
||||
@@ -38,13 +60,32 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
|
||||
</div>
|
||||
<div class="post__text">
|
||||
<p class="small muted post__meta">
|
||||
<span>{post.data.category}</span>
|
||||
<time datetime={post.data.date.toISOString().slice(0, 10)}>{formatDate(post.data.date, lang)}</time>
|
||||
<span>{ui.insights.readingTime(post.data.readingTime)}</span>
|
||||
<span>{item.entry.data.category}</span>
|
||||
<time datetime={item.date.toISOString().slice(0, 10)}>{formatDate(item.date, lang)}</time>
|
||||
<span>{ui.insights.readingTime(item.entry.data.readingTime)}</span>
|
||||
</p>
|
||||
<h2 class="h2">{post.data.title}</h2>
|
||||
{post.data.subtitle && <p class="muted">{post.data.subtitle}</p>}
|
||||
{post.data.lang !== lang && <p class="small muted">{ui.insights.inEnglish}</p>}
|
||||
<h2 class="h2">{item.entry.data.title}</h2>
|
||||
{item.entry.data.subtitle && <p class="muted">{item.entry.data.subtitle}</p>}
|
||||
{item.entry.data.lang !== lang && <p class="small muted">{ui.insights.inEnglish}</p>}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<li class="post" data-type={feedType(item)}>
|
||||
<a class="post__link" href={localePath(lang, `/insights/${item.entry.id}`)} lang={item.entry.data.lang !== lang ? item.entry.data.lang : undefined}>
|
||||
<div class="post__media mention__panel" aria-hidden="true">
|
||||
<span class="mention__source">{item.entry.data.source}</span>
|
||||
<span class="mention__platform">{item.entry.data.platform}</span>
|
||||
</div>
|
||||
<div class="post__text">
|
||||
<p class="small muted post__meta">
|
||||
<span>{m.category(item.entry.data.platform)}</span>
|
||||
<time datetime={item.date.toISOString().slice(0, 10)}>{formatDate(item.date, lang)}</time>
|
||||
<span>{m.source(item.entry.data.source)}</span>
|
||||
</p>
|
||||
<h2 class="h2">{item.entry.data.title}</h2>
|
||||
<p class="muted mention__summary">{item.entry.data.summary}</p>
|
||||
{item.entry.data.lang !== lang && <p class="small muted">{item.entry.data.lang === 'en' ? m.inEnglish : m.inGerman}</p>}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
@@ -54,6 +95,29 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
|
||||
</section>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
/* Filter the list by type. The choice lives in ?type= so a filtered view can be linked;
|
||||
without script every entry shows. */
|
||||
const bar = document.querySelector<HTMLElement>('[data-filters]');
|
||||
const items = document.querySelectorAll<HTMLElement>('[data-feed] > li');
|
||||
if (bar) {
|
||||
const inputs = bar.querySelectorAll<HTMLInputElement>('input[name="insights-type"]');
|
||||
const keys = [...inputs].map((i) => i.value);
|
||||
const apply = (key: string, push: boolean) => {
|
||||
if (!keys.includes(key)) key = 'all';
|
||||
inputs.forEach((i) => { i.checked = i.value === key; });
|
||||
items.forEach((li) => { li.hidden = key !== 'all' && li.dataset.type !== key; });
|
||||
if (push) {
|
||||
const url = new URL(location.href);
|
||||
if (key === 'all') url.searchParams.delete('type'); else url.searchParams.set('type', key);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
};
|
||||
bar.addEventListener('change', (e) => apply((e.target as HTMLInputElement).value, true));
|
||||
apply(new URL(location.href).searchParams.get('type') ?? 'all', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.index__title { grid-column: 1 / -1; }
|
||||
.index__lead { grid-column: 1 / -1; max-width: var(--measure); color: var(--ink-muted); }
|
||||
@@ -62,6 +126,9 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
|
||||
.index__lead { grid-column: 1 / span 5; }
|
||||
}
|
||||
.section--list { padding-bottom: var(--section); }
|
||||
/* Filter row: the shared segmented choice (base.css), as on the research list. */
|
||||
.filters { padding-bottom: var(--space-6); }
|
||||
.post[hidden] { display: none; }
|
||||
.posts { border-top: var(--hairline) solid var(--line); }
|
||||
.post { border-bottom: var(--hairline) solid var(--line); }
|
||||
.post__link {
|
||||
@@ -74,6 +141,18 @@ const posts = (await getCollection('insights')).sort((a, b) => b.data.date.value
|
||||
.post__img, :global(.post__img) { width: 100%; height: auto; aspect-ratio: 3 / 2; object-fit: cover; }
|
||||
.post__text { display: grid; gap: var(--space-4); align-content: start; }
|
||||
.post__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
|
||||
/* A mention has no cover: a typographic panel names who posted it and where. */
|
||||
.mention__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
aspect-ratio: 3 / 2;
|
||||
padding: var(--space-5);
|
||||
background: var(--paper-deep);
|
||||
}
|
||||
.mention__source { font-size: var(--step-title); line-height: var(--leading-title); letter-spacing: var(--track-h2); color: var(--ink-muted); }
|
||||
.mention__platform { font-size: var(--step-small); line-height: var(--leading-small); color: var(--ink-muted); }
|
||||
.mention__summary { max-width: var(--measure); }
|
||||
@media (min-width: 48rem) {
|
||||
.post__link { grid-template-columns: 5fr 7fr; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import { render } from 'astro:content';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import { formatDate, localePath, t, type Lang } from '../i18n/ui';
|
||||
|
||||
interface Props { lang: Lang; mention: CollectionEntry<'mentions'> }
|
||||
const { lang, mention } = Astro.props;
|
||||
const ui = t(lang);
|
||||
const m = ui.insights.mention;
|
||||
const d = mention.data;
|
||||
/* Optional notes of our own below the front matter. */
|
||||
const hasBody = Boolean(mention.body?.trim());
|
||||
const { Content } = await render(mention);
|
||||
---
|
||||
<Base lang={lang} title={d.title} description={d.summary}>
|
||||
<article class="mention">
|
||||
<header class="section section--intro">
|
||||
<div class="container grid">
|
||||
<div class="mention__back">
|
||||
<a class="link" href={localePath(lang, '/insights')}>{ui.insights.back}</a>
|
||||
</div>
|
||||
<div class="mention__head">
|
||||
<p class="small muted mention__meta">
|
||||
<span>{m.category(d.platform)}</span>
|
||||
<time datetime={d.date.toISOString().slice(0, 10)}>{formatDate(d.date, lang)}</time>
|
||||
<span>{m.source(d.source)}</span>
|
||||
</p>
|
||||
<h1 class="h2 mention__title" lang={d.lang !== lang ? d.lang : undefined}>{d.title}</h1>
|
||||
<p class="mention__summary muted" lang={d.lang !== lang ? d.lang : undefined}>{d.summary}</p>
|
||||
{hasBody && <div class="prose mention__notes" lang={d.lang !== lang ? d.lang : undefined}><Content /></div>}
|
||||
<p><a class="link" href={d.url} rel="noopener">{m.open(d.platform)}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{d.embed && (
|
||||
<section class="mention__embed-section" aria-label={m.embedTitle(d.source, d.platform)}>
|
||||
<div class="container grid">
|
||||
<div class="embed hud-frame" data-embed data-src={d.embed} data-title={m.embedTitle(d.source, d.platform)} style={`--embed-h: ${d.embedHeight}px`}>
|
||||
<div class="embed__gate" data-embed-gate>
|
||||
<p class="embed__source">{d.source}</p>
|
||||
<p class="small muted embed__consent">
|
||||
{m.consent(d.platform)} <a href={localePath(lang, '/privacy')}>{m.privacy}</a>
|
||||
</p>
|
||||
<button class="button" type="button" data-embed-load>{m.load(d.platform)}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<script>
|
||||
/* Two-click embed: nothing is requested from the platform until the visitor asks for it. */
|
||||
document.querySelectorAll<HTMLElement>('[data-embed]').forEach((box) => {
|
||||
const button = box.querySelector<HTMLButtonElement>('[data-embed-load]');
|
||||
button?.addEventListener('click', () => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.src = box.dataset.src!;
|
||||
frame.title = box.dataset.title ?? '';
|
||||
frame.loading = 'lazy';
|
||||
frame.className = 'embed__frame';
|
||||
frame.setAttribute('allowfullscreen', '');
|
||||
box.querySelector('[data-embed-gate]')?.remove();
|
||||
box.append(frame);
|
||||
box.toggleAttribute('data-loaded', true);
|
||||
frame.focus();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.mention__back { grid-column: 1 / -1; }
|
||||
.mention__head { grid-column: 1 / -1; display: grid; gap: var(--space-5); justify-items: start; }
|
||||
.mention__meta { display: flex; flex-wrap: wrap; gap: var(--space-4); }
|
||||
.mention__title { text-wrap: balance; }
|
||||
.mention__summary { font-size: var(--step-h3); letter-spacing: var(--track-h3); line-height: var(--leading-h3); max-width: var(--measure); }
|
||||
|
||||
.mention__embed-section { padding-bottom: var(--section); }
|
||||
.embed {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
aspect-ratio: 4 / 3;
|
||||
background: var(--paper-deep);
|
||||
}
|
||||
/* Loaded: the frame takes the post's height. The jump answers the visitor's own click. */
|
||||
.embed[data-loaded] { aspect-ratio: auto; }
|
||||
.embed[data-loaded] { background: var(--paper); }
|
||||
.embed__gate {
|
||||
display: grid;
|
||||
align-content: end;
|
||||
justify-items: start;
|
||||
gap: var(--space-5);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
.embed__source { font-size: var(--step-title); line-height: var(--leading-title); letter-spacing: var(--track-h2); color: var(--ink-muted); }
|
||||
.embed__consent { max-width: 34em; }
|
||||
.embed__consent a { color: var(--ink); text-decoration: underline; text-decoration-thickness: var(--hairline); text-underline-offset: 0.2em; }
|
||||
.embed :global(.embed__frame) { width: 100%; height: var(--embed-h); border: 0; display: block; }
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.mention__back { grid-column: 1 / span 2; }
|
||||
.mention__head { grid-column: 3 / span 8; }
|
||||
/* Half the grid, centred: columns 4 to 9. */
|
||||
.embed { grid-column: 4 / span 6; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,17 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Post from '../../_Post.astro';
|
||||
import Mention from '../../_Mention.astro';
|
||||
|
||||
/* Essays and mentions share /insights/<slug>; a slug used by both would be a defect. */
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('insights');
|
||||
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
const posts = (await getCollection('insights')).map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
const mentions = (await getCollection('mentions')).map((mention) => ({ params: { slug: mention.id }, props: { mention } }));
|
||||
const taken = new Set(posts.map((p) => p.params.slug));
|
||||
const clash = mentions.find((m) => taken.has(m.params.slug));
|
||||
if (clash) throw new Error(`Insights slug used by an essay and a mention: ${clash.params.slug}`);
|
||||
return [...posts, ...mentions];
|
||||
}
|
||||
const { post } = Astro.props;
|
||||
const { post, mention } = Astro.props;
|
||||
---
|
||||
<Post lang="de" post={post} />
|
||||
{post ? <Post lang="de" post={post} /> : <Mention lang="de" mention={mention!} />}
|
||||
@@ -20,6 +20,9 @@ const ui = t('de');
|
||||
<h2>Benachrichtigung zur CipherVerifier-Alpha</h2>
|
||||
<p>Wenn Sie sich für die Benachrichtigung zur Alpha von CipherVerifier eintragen, speichern wir Ihre E-Mail-Adresse und den Zeitpunkt Ihrer Einwilligung, um Ihnen mitzuteilen, wann die erste Alpha veröffentlicht ist. Wir nutzen sie für nichts anderes und geben sie nicht an Dritte weiter. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. Sie können Ihre Einwilligung jederzeit per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a> widerrufen; wir löschen Ihre Adresse dann. Nach dem Versand der Benachrichtigung löschen wir sie ebenfalls.</p>
|
||||
|
||||
<h2>Eingebettete LinkedIn-Beiträge</h2>
|
||||
<p>Einige Seiten unter Einblicke können einen Beitrag von LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Irland) anzeigen. Beim Aufruf der Seite wird der Beitrag nicht geladen. Erst wenn Sie auf die Schaltfläche zum Laden klicken, verbindet sich Ihr Browser mit den Servern von LinkedIn; diese erhalten Ihre IP-Adresse und die aufgerufene Seite und können Cookies setzen. Sind Sie bei LinkedIn angemeldet, kann LinkedIn den Besuch Ihrem Konto zuordnen. Rechtsgrundlage ist Ihre Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO, die Sie mit diesem Klick erteilen. Zur Verarbeitung durch LinkedIn siehe dessen Datenschutzrichtlinie unter <a href="https://de.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p>
|
||||
|
||||
<h2>Recht auf Auskunft, Berichtigung, Sperrung oder Löschung</h2>
|
||||
<p>Nach dem anwendbaren Datenschutzrecht haben Sie ein Recht auf unentgeltliche Auskunft über Ihre gespeicherten Daten sowie ggf. ein Recht auf Berichtigung, Sperrung oder Löschung dieser Daten. Wenden Sie sich bei weitergehenden Fragen zum Datenschutz und zur Verarbeitung Ihrer personenbezogenen Daten, bei Auskünften oder zur Ausübung Ihres Rechts auf Berichtigung, Sperrung oder Löschung oder für den Fall des Widerrufs einer Einwilligung bitte per E-Mail an <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>. Selbstverständlich können Sie auch die im Impressum angegebenen Kontaktdaten oder das Kontaktformular auf der Website nutzen. CipherCell verarbeitet und nutzt die von Ihnen mitgeteilten Informationen ausschließlich für die Bearbeitung Ihres Anliegens.</p>
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
import Post from '../_Post.astro';
|
||||
import Mention from '../_Mention.astro';
|
||||
|
||||
/* Essays and mentions share /insights/<slug>; a slug used by both would be a defect. */
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('insights');
|
||||
return posts.map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
const posts = (await getCollection('insights')).map((post) => ({ params: { slug: post.id }, props: { post } }));
|
||||
const mentions = (await getCollection('mentions')).map((mention) => ({ params: { slug: mention.id }, props: { mention } }));
|
||||
const taken = new Set(posts.map((p) => p.params.slug));
|
||||
const clash = mentions.find((m) => taken.has(m.params.slug));
|
||||
if (clash) throw new Error(`Insights slug used by an essay and a mention: ${clash.params.slug}`);
|
||||
return [...posts, ...mentions];
|
||||
}
|
||||
const { post } = Astro.props;
|
||||
const { post, mention } = Astro.props;
|
||||
---
|
||||
<Post lang="en" post={post} />
|
||||
{post ? <Post lang="en" post={post} /> : <Mention lang="en" mention={mention!} />}
|
||||
@@ -20,6 +20,9 @@ const ui = t('en');
|
||||
<h2>CipherVerifier alpha notification</h2>
|
||||
<p>If you register for the CipherVerifier alpha notification, we store your email address and the time of your consent in order to tell you when the first alpha is released. We use it for nothing else and do not pass it on to third parties. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR. You can withdraw your consent at any time by email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>; we then delete your address. We also delete it once the notification has been sent.</p>
|
||||
|
||||
<h2>Embedded LinkedIn posts</h2>
|
||||
<p>Some pages in Insights can show a post from LinkedIn (LinkedIn Ireland Unlimited Company, Wilton Place, Dublin 2, Ireland). The post is not loaded when you open the page. Only when you click the button to load it does your browser connect to LinkedIn's servers, which receive your IP address and the page you are on and may set cookies; if you are logged in to LinkedIn, it can link the visit to your account. The legal basis is your consent under Art. 6 para. 1 lit. a GDPR, given by that click. For LinkedIn's processing, see its privacy policy at <a href="https://www.linkedin.com/legal/privacy-policy" rel="noopener">linkedin.com/legal/privacy-policy</a>.</p>
|
||||
|
||||
<h2>Right to information, correction, blocking, or deletion</h2>
|
||||
<p>Under applicable data protection law, you have the right to obtain information about your stored data free of charge and, if necessary, the right to correct, block, or delete this data. If you have any further questions about data protection and the processing of your personal data, if you require information, or if you wish to exercise your right to correction, blocking, or deletion, or in the event of revocation of consent, please send an email to <a href="mailto:office@ciphercell.de">office@ciphercell.de</a>. You can also use the contact details provided in the legal notice or the contact form on the website. CipherCell processes and uses the information you provide exclusively for the purpose of processing your request.</p>
|
||||
|
||||
|
||||
Reference in new issue
Block a user