Merge pull request #228 from goteo/feat/blog-template

Feat: blog template
This commit is contained in:
2026-04-13 12:41:04 +02:00
committed by GitHub
9 changed files with 437 additions and 169 deletions
+196
View File
@@ -0,0 +1,196 @@
<script lang="ts">
import { marked } from "marked";
import { twMerge, type ClassNameValue } from "tailwind-merge";
import ShareButton from "./Share/ShareButton.svelte";
interface BlogPost {
title?: string;
description?: string;
cover?: string;
date: Date;
author?: { name: string; avatar: string };
body?: string;
}
interface Props {
post: BlogPost;
class?: ClassNameValue;
}
let { post, class: className = "" }: Props = $props();
const containerClasses = $derived(twMerge("flex justify-center min-h-screen", className));
const parsedBody = $derived(marked(post.body ?? ""));
</script>
<div class={containerClasses}>
<div class="w-full max-w-4xl p-4" id="top">
<h1>{post.title}</h1>
<p class="text-content pb-4">
{post.description}
</p>
{#if post.cover}
<img src={post.cover} class="big-img" alt={post.title} />
{/if}
<div class="meta-container pt-6">
<div class="profile-section">
<img src={post.author?.avatar} class="avatar" alt={post.author?.name} />
<div class="info">
<p class="author-name">{post.author?.name}</p>
<p class="date">{post.date}</p>
</div>
</div>
<ShareButton shareText={post.title ?? ""} variant="blog" />
</div>
<div
class="blog-body marked-content mt-8 flex flex-col gap-6"
style="overflow-wrap: break-word; word-wrap: break-word; word-break: break-word;"
>
{@html parsedBody}
</div>
</div>
</div>
<style>
h1 {
padding-bottom: 16px;
color: var(--color-secondary);
font-size: 40px;
font-weight: var(--font-weight-black);
line-height: 1.2;
}
.big-img {
width: 100%;
max-width: 898px;
height: 400px;
border-radius: 16px;
object-fit: cover;
}
.meta-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.profile-section {
display: flex;
align-items: center;
gap: 16px;
}
.avatar {
border-radius: 8px;
width: 64px;
height: 64px;
object-fit: cover;
}
.author-name {
color: var(--color-secondary);
font-weight: 700;
margin: 0;
}
.date {
color: #575757;
font-size: 14px;
margin: 0;
}
.btn-share {
display: flex;
padding: 8px 16px;
align-items: center;
gap: 8px;
border-radius: 16px;
border: 1px solid var(--color-secondary);
background: transparent;
cursor: pointer;
font-weight: bold;
color: var(--color-secondary);
transition: opacity 0.2s;
}
.blog-body :global(h2) {
padding-top: 24px;
padding-bottom: 8px;
color: var(--color-secondary);
font-family: var(--font-body);
font-size: 24px;
font-weight: 700;
line-height: 32px;
}
.blog-body :global(h3) {
padding-top: 16px;
padding-bottom: 8px;
color: var(--color-secondary);
font-family: var(--font-body);
font-size: 20px;
font-weight: 700;
line-height: 28px;
}
.blog-body :global(p) {
color: var(--color-content);
font-family: var(--font-body);
font-size: 16px;
font-weight: 400;
line-height: 24px;
}
.blog-body :global(blockquote) {
border-left: 4px solid var(--color-secondary);
padding-left: 16px;
margin: 16px 0;
color: var(--color-content);
font-style: italic;
}
.blog-body :global(ul),
.blog-body :global(ol) {
padding-left: 24px;
color: var(--color-content);
font-size: 16px;
line-height: 24px;
list-style: initial;
}
.blog-body :global(ol) {
list-style: decimal;
}
.blog-body :global(li) {
margin-bottom: 4px;
}
.blog-body :global(strong) {
font-weight: 700;
color: var(--color-secondary);
}
.blog-body :global(em) {
font-style: italic;
}
.blog-body :global(a) {
color: var(--color-primary);
text-decoration: underline;
overflow-wrap: break-word;
word-break: break-word;
}
.blog-body :global(pre) {
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
max-width: 100%;
}
</style>
@@ -0,0 +1,24 @@
<script lang="ts">
import { t } from "../../../i18n/store";
import WebIcon from "../../../svgs/WebIcon.svelte";
import Tooltip from "../../Admin/Tooltip.svelte";
interface Props {
url?: string;
}
let { url = "" }: Props = $props();
</script>
<Tooltip
text={$t("contributions.tootip.copied")}
tooltipClass="bg-secondary -translate-x-[50%]"
className="cursor-copy shrink-0 w-auto h-auto"
>
<button
class="flex h-23 w-23 items-center justify-center rounded-2xl shadow-[0_1px_3px_0_rgba(0,0,0,0.1)]"
onclick={() => navigator.clipboard.writeText(url)}
>
<WebIcon />
</button>
</Tooltip>
@@ -0,0 +1,41 @@
<script lang="ts">
import FacebookIcon from "../../../svgs/FacebookIcon.svelte";
interface Props {
url?: string;
appId?: string;
}
let { url = "", appId = import.meta.env.PUBLIC_FACEBOOK_APP_ID }: Props = $props();
let fbWindow: Window | null = null;
const fbShareUrl = $derived(() => {
const encoded = encodeURIComponent(url);
return `https://www.facebook.com/dialog/share?app_id=${appId}&display=popup&href=${encoded}&redirect_uri=${encoded}`;
});
function openFbPopup() {
const width = 626;
const height = 436;
const left = window.screenX + (window.innerWidth - width) / 2;
const top = window.screenY + (window.innerHeight - height) / 2;
if (fbWindow && !fbWindow.closed) {
fbWindow.close();
}
fbWindow = window.open(
fbShareUrl(),
"facebook-share-dialog",
`width=${width},height=${height},top=${top},left=${left}`,
);
}
</script>
<button
onclick={openFbPopup}
class="flex h-23 w-23 items-center justify-center rounded-2xl shadow-[0_1px_3px_0_rgba(0,0,0,0.1)]"
>
<FacebookIcon />
</button>
@@ -0,0 +1,85 @@
<script lang="ts">
import { Modal } from "flowbite-svelte";
import { t } from "../../../i18n/store";
import CodeIcon from "../../../svgs/CodeIcon.svelte";
import Loader from "../../../svgs/Loader.svelte";
interface Props {
url?: string;
}
let { url = "" }: Props = $props();
const iframeCode = $derived(
`<iframe frameborder="0" height="492px" src="${url}" width="300px" scrolling="no"></iframe>`,
);
let iframeModal = $state(false);
let iframeLoaded = $state(false);
let copied = $state(false);
$effect(() => {
if (iframeModal) {
iframeLoaded = false;
}
});
</script>
<button
class="flex h-23 w-23 items-center justify-center rounded-2xl shadow-[0_1px_3px_0_rgba(0,0,0,0.1)]"
onclick={() => (iframeModal = true)}
>
<CodeIcon />
</button>
<Modal
bind:open={iframeModal}
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent cursor-pointer"
class="left-1/2! max-w-[600px] p-4 backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
title={$t("project.share.iframe-modal.title")}
headerClass="py-2 text-secondary text-2xl"
>
<p>{$t("project.share.iframe-modal.description")}</p>
<div class="flex w-full flex-row items-stretch justify-center gap-4">
<div class="flex w-1/2 items-center justify-center rounded-lg border p-4">
{#if !iframeLoaded}
<Loader />
{/if}
<iframe
onload={() => (iframeLoaded = true)}
src={url}
width="100%"
height="492px"
frameborder="0"
scrolling="no"
title="Project widget preview"
class:hidden={!iframeLoaded}
class="grayscale"
></iframe>
</div>
<div class="flex w-1/2 rounded-lg border p-4">
<textarea
readonly
class="w-full resize-none border-none focus:ring-0 focus:outline-none"
rows="4">{iframeCode}</textarea
>
</div>
</div>
<button
class="bg-primary text-secondary h-auto w-full cursor-pointer rounded-3xl px-6 py-4 font-bold"
class:bg-primary={!copied}
class:bg-variant1={copied}
onclick={() => {
navigator.clipboard.writeText(iframeCode);
copied = true;
setTimeout(() => (copied = false), 1000);
}}
>
{copied
? $t("project.share.iframe-modal.btnCopied")
: $t("project.share.iframe-modal.btnCopy")}
</button>
</Modal>
@@ -0,0 +1,60 @@
<script lang="ts">
import { Modal } from "flowbite-svelte";
import CopyUrl from "./CopyUrl.svelte";
import Facebook from "./Facebook.svelte";
import Iframe from "./Iframe.svelte";
import X from "./X.svelte";
import { t } from "../../../i18n/store";
import ShareIcon from "../../../svgs/ShareIcon.svelte";
interface Props {
shareText?: string;
variant?: "blog" | "project";
projectSlug?: string;
url?: string;
}
let { shareText = "", variant = "project", projectSlug = "", url = "" }: Props = $props();
const widgetUrl = $derived(`https://www.goteo.org/widget/project/${projectSlug}`);
let openModal = $state(false);
const modalTitle = $derived(
variant === "blog" ? $t("blog.share.modal.title") : $t("project.share.modal.title"),
);
const modalDescription = $derived(
variant === "blog"
? $t("blog.share.modal.description")
: $t("project.share.modal.description"),
);
</script>
<button
onclick={() => (openModal = true)}
class="text-secondary flex cursor-pointer flex-row items-center gap-2 p-2 font-bold"
>
<ShareIcon />
{$t("project.actions.share")}
</button>
<Modal
bind:open={openModal}
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent cursor-pointer"
class="fixed top-1/2 left-1/2 w-full max-w-[475px] -translate-x-1/2 -translate-y-1/2 rounded-3xl bg-white p-6 shadow-lg backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
title={modalTitle}
headerClass="py-2 text-secondary text-2xl"
>
<p>{modalDescription}</p>
<div class="flex flex-row items-center justify-center gap-6">
<CopyUrl {url} />
<Facebook {url} />
<X text={shareText} {url} />
{#if variant === "project"}
<Iframe url={widgetUrl} />
{/if}
</div>
</Modal>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import XIcon from "../../../svgs/XIcon.svelte";
interface Props {
text?: string;
url?: string;
}
let { text = "", url = "" }: Props = $props();
const encodedUrl = $derived(encodeURIComponent(url));
</script>
<a
href={`https://twitter.com/intent/tweet?url=${encodedUrl}&text=${text}`}
target="_blank"
rel="noopener noreferrer"
class="flex h-23 w-23 items-center justify-center rounded-2xl shadow-[0_1px_3px_0_rgba(0,0,0,0.1)]"
>
<XIcon />
</a>
-167
View File
@@ -1,167 +0,0 @@
<script lang="ts">
import { Modal } from "flowbite-svelte";
import { onMount } from "svelte";
import { t } from "../../i18n/store";
import CodeIcon from "../../svgs/CodeIcon.svelte";
import FacebookIcon from "../../svgs/FacebookIcon.svelte";
import Loader from "../../svgs/Loader.svelte";
import ShareIcon from "../../svgs/ShareIcon.svelte";
import WebIcon from "../../svgs/WebIcon.svelte";
import XIcon from "../../svgs/XIcon.svelte";
import Tooltip from "../Admin/Tooltip.svelte";
import type { Project } from "../../openapi/client/index";
const appId = "184483011630708";
/* TODO: Implement URL dynamically */
const iframeCode = `<iframe frameborder="0" height="492px" src="https://www.goteo.org/widget/project/mostra-de-cinema-arab-i-mediterrani-de-catalunya" width="300px" scrolling="no"></iframe>`;
let { project }: { project: Project } = $props();
let currentUrl = $state("");
let fbShareUrl = $state("");
let openModal = $state(false);
let iframeModal = $state(false);
let iframeLoaded = $state(false);
let copied = $state(false);
let fbWindow: Window | null = null;
function openFbPopup() {
const width = 626;
const height = 436;
const left = window.screenX + (window.innerWidth - width) / 2;
const top = window.screenY + (window.innerHeight - height) / 2;
if (fbWindow && !fbWindow.closed) {
fbWindow.close();
}
fbWindow = window.open(
fbShareUrl,
"facebook-share-dialog",
`width=${width},height=${height},top=${top},left=${left}`,
);
}
$effect(() => {
if (iframeModal) {
iframeLoaded = false;
}
});
onMount(() => {
currentUrl = encodeURIComponent(window.location.href);
// currentUrl = encodeURIComponent("https://developers.facebook.com/docs/");
fbShareUrl = `https://www.facebook.com/dialog/share?app_id=${appId}&display=popup&href=${currentUrl}&redirect_uri=${currentUrl}`;
});
</script>
<button
onclick={() => (openModal = true)}
class="text-secondary flex cursor-pointer flex-row items-center gap-2 p-2 font-bold"
>
<ShareIcon />
{$t("project.actions.share")}
</button>
<Modal
bind:open={openModal}
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent cursor-pointer"
class="fixed top-1/2 left-1/2 w-full max-w-[475px] -translate-x-1/2 -translate-y-1/2 rounded-3xl bg-white p-6 shadow-lg backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
title={$t("project.share.modal.title")}
headerClass="py-2 text-secondary text-2xl"
>
<p>{$t("project.share.modal.description")}</p>
<div class="flex flex-row items-center justify-center gap-6">
<Tooltip
text={$t("contributions.tootip.copied")}
tooltipClass="bg-secondary -translate-x-[50%]"
className="cursor-copy shrink-0 w-auto h-auto"
>
<button
class="flex h-23 w-23 items-center justify-center rounded-2xl shadow-[0_1px_3px_0_rgba(0,0,0,0.1)]"
onclick={() => navigator.clipboard.writeText(window.location.href)}
>
<WebIcon />
</button>
</Tooltip>
<!-- TODO: Implement Facebook share popup -->
<button
onclick={openFbPopup}
class="flex h-23 w-23 items-center justify-center rounded-2xl shadow-[0_1px_3px_0_rgba(0,0,0,0.1)]"
>
<FacebookIcon />
</button>
<a
href={`https://twitter.com/intent/tweet?url=${currentUrl}&text=${project.title}`}
target="_blank"
rel="noopener noreferrer"
class="flex h-23 w-23 items-center justify-center rounded-2xl shadow-[0_1px_3px_0_rgba(0,0,0,0.1)]"
>
<XIcon />
</a>
<button
class="flex h-23 w-23 items-center justify-center rounded-2xl shadow-[0_1px_3px_0_rgba(0,0,0,0.1)]"
onclick={() => (iframeModal = true)}
>
<CodeIcon />
</button>
</div>
</Modal>
<Modal
bind:open={iframeModal}
closeBtnClass="top-7 end-7 bg-transparent text-secondary hover:bg-transparent hover:text-secondary hover:scale-110 transition-transform duration-200 transform focus:ring-0 shadow-none dark:text-secondary dark:hover:text-secondary dark:hover:bg-transparent cursor-pointer"
class="!left-1/2 max-w-[600px] p-4 backdrop:bg-[#878282B2] backdrop:backdrop-blur-[5px]"
title={$t("project.share.iframe-modal.title")}
headerClass="py-2 text-secondary text-2xl"
>
<p>{$t("project.share.iframe-modal.description")}</p>
<div class="flex w-full flex-row items-stretch justify-center gap-4">
<div class="flex w-1/2 items-center justify-center rounded-lg border p-4">
{#if !iframeLoaded}
<Loader />
{/if}
<iframe
onload={() => (iframeLoaded = true)}
src="https://www.goteo.org/widget/project/mostra-de-cinema-arab-i-mediterrani-de-catalunya"
width="100%"
height="492px"
frameborder="0"
scrolling="no"
title="Project widget preview"
class:hidden={!iframeLoaded}
class="grayscale"
></iframe>
</div>
<div class="flex w-1/2 rounded-lg border p-4">
<textarea
readonly
class="w-full resize-none border-none focus:ring-0 focus:outline-none"
rows="4">{iframeCode}</textarea
>
</div>
</div>
<button
class="bg-primary text-secondary h-auto w-full cursor-pointer rounded-3xl px-6 py-4 font-bold"
class:bg-primary={!copied}
class:bg-variant1={copied}
onclick={() => {
navigator.clipboard.writeText(iframeCode);
copied = true;
setTimeout(() => (copied = false), 1000);
}}
>
{copied
? $t("project.share.iframe-modal.btnCopied")
: $t("project.share.iframe-modal.btnCopy")}
</button>
</Modal>
+2 -2
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import Banner from "./Banner.svelte";
import Card from "./Card.svelte";
import Sharebutton from "./Sharebutton.svelte";
import Tabs from "./Tabs.svelte";
import TopRewards from "./TopRewards.svelte";
import { setLocale, t } from "../../i18n/store";
@@ -17,6 +16,7 @@
import Countdown from "../Countdown.svelte";
import LanguagesDropdown from "../LanguagesDropdown.svelte";
import Button from "../library/Button.svelte";
import Sharebutton from "../library/Share/ShareButton.svelte";
import Toast from "../library/Toast.svelte";
import Player from "../Player/Player.svelte";
import Tags from "../Tags.svelte";
@@ -200,7 +200,7 @@
<div class="mb-12 flex w-full flex-col justify-between gap-4 lg:flex-row">
<Tags {project} />
<div class="flex flex-row justify-between gap-6">
<Sharebutton {project} />
<Sharebutton shareText={project.title ?? ""} projectSlug={project.slug ?? ""} />
<Button kind="invert" size="sm" class="px-0">
<RememberIcon />
{$t("project.actions.remember")}
+8
View File
@@ -1046,6 +1046,14 @@
}
},
"loading": "Cargando...",
"blog": {
"share": {
"modal": {
"title": "Difunde este blog por el mundo",
"description": "Puedes copiar el enlace para enviarlo y también compartirla en Facebook o X."
}
}
},
"lang": {
"error": {
"notAvailable": "El idioma seleccionado \"{{lang}}\" no está disponible para este proyecto. Mostrando contenido en el idioma disponible."