feat(ghost): add news publishing service
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
TZ=Europe/Madrid
|
||||
|
||||
GHOST_IMAGE=ghost:6-alpine
|
||||
GHOST_DOMAIN=news.sherlockhomeless.net
|
||||
GHOST_CONTENT_PATH=/opt/ghost/content
|
||||
|
||||
GHOST_DB_IMAGE=mysql:8.4
|
||||
GHOST_DB_DATA_PATH=/opt/ghost/mysql
|
||||
GHOST_DB_NAME=ghost
|
||||
GHOST_DB_USER=ghost
|
||||
GHOST_DB_PASSWORD=CHANGE_ME
|
||||
GHOST_DB_ROOT_PASSWORD=CHANGE_ME
|
||||
|
||||
GHOST_MAIL_FROM="News <news@thehomelesssherlock.com>"
|
||||
|
||||
GHOST_OPS_ALLOWED_EMAIL=you@example.com
|
||||
GHOST_CONTENT_API_KEY=CHANGE_ME
|
||||
|
||||
TRAEFIK_DOCKER_NETWORK=proxy
|
||||
TRAEFIK_ENTRYPOINT_SECURE=websecure
|
||||
TRAEFIK_CERTRESOLVER=letsencrypt
|
||||
@@ -0,0 +1,36 @@
|
||||
# Ghost News
|
||||
|
||||
Ghost publica `news.sherlockhomeless.net` como archivo de informes diarios.
|
||||
|
||||
## Despliegue
|
||||
|
||||
```bash
|
||||
cd ghost
|
||||
docker compose --env-file .env config
|
||||
docker compose --env-file .env up -d
|
||||
```
|
||||
|
||||
El dominio se expone por Traefik en la red `proxy` y queda protegido con `crowdsec-bouncer@file`.
|
||||
La administracion queda bajo el login propio de Ghost en `/ghost/`.
|
||||
|
||||
## Primer acceso
|
||||
|
||||
Abre:
|
||||
|
||||
```text
|
||||
https://news.sherlockhomeless.net/ghost/
|
||||
```
|
||||
|
||||
Completa el usuario administrador inicial de Ghost.
|
||||
|
||||
## Integracion futura con n8n
|
||||
|
||||
El flujo recomendado es:
|
||||
|
||||
```text
|
||||
n8n genera informe completo
|
||||
-> publica post en Ghost por Admin API
|
||||
-> envia email/Telegram con resumen + enlace
|
||||
```
|
||||
|
||||
Para publicar desde n8n no hace falta atravesar Traefik: puede llamar al servicio interno `http://ghost:2368` si se conecta la red correspondiente o se usa una ruta interna dedicada.
|
||||
@@ -0,0 +1,108 @@
|
||||
services:
|
||||
ghost-db:
|
||||
image: ${GHOST_DB_IMAGE}
|
||||
container_name: ghost-db
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
prune.protect: "true"
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${GHOST_DB_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${GHOST_DB_NAME}
|
||||
MYSQL_USER: ${GHOST_DB_USER}
|
||||
MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
|
||||
TZ: ${TZ}
|
||||
volumes:
|
||||
- ${GHOST_DB_DATA_PATH}:/var/lib/mysql:Z
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u${GHOST_DB_USER} -p${GHOST_DB_PASSWORD} --silent"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
start_period: 30s
|
||||
networks:
|
||||
- ghost_internal
|
||||
|
||||
ghost:
|
||||
image: ${GHOST_IMAGE}
|
||||
container_name: ghost
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
ghost-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
TZ: ${TZ}
|
||||
url: https://${GHOST_DOMAIN}
|
||||
database__client: mysql
|
||||
database__connection__host: ghost-db
|
||||
database__connection__user: ${GHOST_DB_USER}
|
||||
database__connection__password: ${GHOST_DB_PASSWORD}
|
||||
database__connection__database: ${GHOST_DB_NAME}
|
||||
mail__transport: SMTP
|
||||
mail__from: ${GHOST_MAIL_FROM}
|
||||
mail__options__host: mail-relay
|
||||
mail__options__port: 587
|
||||
mail__options__secure: "false"
|
||||
mail__options__ignoreTLS: "true"
|
||||
volumes:
|
||||
- ${GHOST_CONTENT_PATH}:/var/lib/ghost/content:Z
|
||||
networks:
|
||||
- ghost_internal
|
||||
- proxy
|
||||
- mail_internal
|
||||
labels:
|
||||
prune.protect: "true"
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: "${TRAEFIK_DOCKER_NETWORK}"
|
||||
traefik.http.routers.ghost.rule: "Host(`${GHOST_DOMAIN}`)"
|
||||
traefik.http.routers.ghost.entrypoints: "${TRAEFIK_ENTRYPOINT_SECURE}"
|
||||
traefik.http.routers.ghost.tls: "true"
|
||||
traefik.http.routers.ghost.tls.certresolver: "${TRAEFIK_CERTRESOLVER}"
|
||||
traefik.http.routers.ghost.service: "ghost"
|
||||
traefik.http.routers.ghost.middlewares: "crowdsec-bouncer@file"
|
||||
traefik.http.services.ghost.loadbalancer.server.port: "2368"
|
||||
homepage.group: "Productividad"
|
||||
homepage.name: "News"
|
||||
homepage.icon: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/ghost.svg"
|
||||
homepage.description: "Briefing diario y archivo de informes"
|
||||
homepage.href: "https://${GHOST_DOMAIN}/"
|
||||
homepage.siteMonitor: "http://ghost:2368/"
|
||||
homepage.weight: "570"
|
||||
|
||||
ghost-ops-gate:
|
||||
image: python:3.12-alpine
|
||||
container_name: ghost-ops-gate
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- ghost
|
||||
environment:
|
||||
OPS_ALLOWED_EMAIL: ${GHOST_OPS_ALLOWED_EMAIL}
|
||||
GHOST_CONTENT_API_KEY: ${GHOST_CONTENT_API_KEY}
|
||||
command: ["python", "/app/ops_gate.py"]
|
||||
volumes:
|
||||
- ./ops-gate:/app:ro,Z
|
||||
networks:
|
||||
- ghost_internal
|
||||
- proxy
|
||||
labels:
|
||||
prune.protect: "true"
|
||||
traefik.enable: "true"
|
||||
traefik.docker.network: "${TRAEFIK_DOCKER_NETWORK}"
|
||||
traefik.http.routers.ghost-ops-gate.rule: "Host(`${GHOST_DOMAIN}`) && (Path(`/ops`) || Path(`/ops/`))"
|
||||
traefik.http.routers.ghost-ops-gate.entrypoints: "${TRAEFIK_ENTRYPOINT_SECURE}"
|
||||
traefik.http.routers.ghost-ops-gate.tls: "true"
|
||||
traefik.http.routers.ghost-ops-gate.tls.certresolver: "${TRAEFIK_CERTRESOLVER}"
|
||||
traefik.http.routers.ghost-ops-gate.priority: "100"
|
||||
traefik.http.routers.ghost-ops-gate.middlewares: "crowdsec-bouncer@file"
|
||||
traefik.http.routers.ghost-ops-gate.service: "ghost-ops-gate"
|
||||
traefik.http.services.ghost-ops-gate.loadbalancer.server.port: "8080"
|
||||
|
||||
networks:
|
||||
ghost_internal:
|
||||
driver: bridge
|
||||
labels:
|
||||
prune.protect: "true"
|
||||
mail_internal:
|
||||
external: true
|
||||
proxy:
|
||||
external: true
|
||||
@@ -0,0 +1,113 @@
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from html import escape as html_escape
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
GHOST_URL = "http://ghost:2368"
|
||||
ALLOWED_EMAIL = os.environ["OPS_ALLOWED_EMAIL"].strip().lower()
|
||||
CONTENT_API_KEY = os.environ["GHOST_CONTENT_API_KEY"].strip()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_safe(self, **fields):
|
||||
print(json.dumps(fields, sort_keys=True), file=sys.stderr, flush=True)
|
||||
|
||||
def redirect_to_ghost_404(self):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", "/404/")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
if self.path not in ("/ops", "/ops/"):
|
||||
self.redirect_to_ghost_404()
|
||||
return
|
||||
|
||||
headers = {
|
||||
"Host": "news.sherlockhomeless.net",
|
||||
"X-Forwarded-Proto": "https",
|
||||
"User-Agent": self.headers.get("User-Agent", "ghost-ops-gate"),
|
||||
}
|
||||
cookie = self.headers.get("Cookie")
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
cookie_names = []
|
||||
if cookie:
|
||||
cookie_names = [part.split("=", 1)[0].strip() for part in cookie.split(";") if "=" in part]
|
||||
|
||||
try:
|
||||
member_req = Request(f"{GHOST_URL}/members/api/member/", headers=headers)
|
||||
with urlopen(member_req, timeout=10) as member_response:
|
||||
self.log_safe(path=self.path, cookie_names=cookie_names, member_status=member_response.status)
|
||||
if member_response.status != 200:
|
||||
self.redirect_to_ghost_404()
|
||||
return
|
||||
member_payload = json.loads(member_response.read().decode("utf-8") or "{}")
|
||||
member = member_payload.get("member") or member_payload
|
||||
self.log_safe(path=self.path, member_payload_keys=sorted(member_payload.keys()), member_keys=sorted(member.keys()))
|
||||
member_email = str(member.get("email", "")).lower()
|
||||
self.log_safe(path=self.path, member_email_present=bool(member_email), member_email_match=member_email == ALLOWED_EMAIL)
|
||||
if member_email != ALLOWED_EMAIL:
|
||||
self.redirect_to_ghost_404()
|
||||
return
|
||||
|
||||
req = Request(
|
||||
f"{GHOST_URL}/ghost/api/content/posts/"
|
||||
f"?key={CONTENT_API_KEY}"
|
||||
"&filter=tag:hash-private-ops"
|
||||
"&fields=title,slug,url,published_at,custom_excerpt"
|
||||
"&order=published_at%20desc"
|
||||
"&limit=50",
|
||||
headers=headers,
|
||||
)
|
||||
with urlopen(req, timeout=10) as response:
|
||||
posts_payload = json.loads(response.read().decode("utf-8") or "{}")
|
||||
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError):
|
||||
self.redirect_to_ghost_404()
|
||||
return
|
||||
|
||||
posts = posts_payload.get("posts") or []
|
||||
if not posts:
|
||||
self.redirect_to_ghost_404()
|
||||
return
|
||||
|
||||
items = "\n".join(
|
||||
f'<li style="margin:14px 0;"><a href="/ops/{post["slug"]}/" '
|
||||
f'style="color:#1d4ed8;text-decoration:none;font-weight:700;">'
|
||||
f'{html_escape(post["title"])}</a>'
|
||||
f'<div style="color:#64748b;font-size:13px;margin-top:4px;">'
|
||||
f'{html_escape((post.get("published_at") or "")[:10])}</div></li>'
|
||||
for post in posts
|
||||
)
|
||||
body = f"""<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>Private Ops</title>
|
||||
</head>
|
||||
<body style="margin:0;background:#f8fafc;color:#111827;font-family:Arial,Helvetica,sans-serif;">
|
||||
<main style="max-width:860px;margin:0 auto;padding:42px 20px;">
|
||||
<h1 style="font-size:32px;line-height:1.2;margin:0 0 22px;">Private Ops</h1>
|
||||
<ul style="list-style:none;padding:0;margin:0;">{items}</ul>
|
||||
</main>
|
||||
</body>
|
||||
</html>""".encode("utf-8")
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "private, no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
|
||||
@@ -0,0 +1,140 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{@site.locale}}">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="{{asset "built/screen.css"}}">
|
||||
|
||||
{{#is "post"}}
|
||||
{{#post}}
|
||||
{{#has tag="#private-ops"}}
|
||||
{{#if access}}
|
||||
<title>{{meta_title page=(t " (Page %)")}}</title>
|
||||
{{ghost_head}}
|
||||
{{else}}
|
||||
<title>No encontrado</title>
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<title>{{meta_title page=(t " (Page %)")}}</title>
|
||||
{{ghost_head}}
|
||||
{{/has}}
|
||||
{{/post}}
|
||||
{{else}}
|
||||
<title>{{meta_title page=(t " (Page %)")}}</title>
|
||||
{{ghost_head}}
|
||||
{{/is}}
|
||||
</head>
|
||||
|
||||
<body class="{{body_class}}{{{block "body_class"}}} is-head-{{#match @custom.navigation_layout "Logo on the left"}}left-logo{{else match @custom.navigation_layout "Logo in the middle"}}middle-logo{{else}}stacked{{/match}}{{#match @custom.title_font "=" "Elegant serif"}} has-serif-title{{/match}}{{#match @custom.body_font "=" "Elegant serif"}} has-serif-body{{/match}}{{#is "tag"}}{{#if tag.feature_image}} is-head-transparent{{/if}}{{/is}}{{#match @custom.header_style "Accent color"}} is-head-brand{{/match}}{{#match @custom.header_style "Dark"}} is-head-dark{{/match}}">
|
||||
<div class="gh-site">
|
||||
|
||||
<header id="gh-head" class="gh-head gh-outer">
|
||||
<div class="gh-head-inner gh-inner">
|
||||
<div class="gh-head-brand">
|
||||
<div class="gh-head-brand-wrapper">
|
||||
{{#is "home"}}<h1 class="gh-head-logo">{{/is}}
|
||||
<a {{^is "home"}}class="gh-head-logo"{{/is}} href="{{@site.url}}">
|
||||
{{#if @site.logo}}
|
||||
<img src="{{@site.logo}}" alt="{{@site.title}}">
|
||||
{{#if @custom.white_publication_logo_for_transparent_header}}
|
||||
<img src="{{@custom.white_publication_logo_for_transparent_header}}" alt="{{@site.title}}">
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{@site.title}}
|
||||
{{/if}}
|
||||
</a>
|
||||
{{#is "home"}}</h1>{{/is}}
|
||||
</div>
|
||||
<button class="gh-search gh-icon-btn" aria-label="{{t "Search this site"}}" data-ghost-search>{{> "icons/search"}}</button>
|
||||
<button class="gh-burger" aria-label="{{t "Toggle menu"}}"></button>
|
||||
</div>
|
||||
|
||||
<nav class="gh-head-menu">
|
||||
{{navigation}}
|
||||
{{#unless @site.members_enabled}}
|
||||
{{#match @custom.navigation_layout "Stacked"}}
|
||||
<button class="gh-search gh-icon-btn" aria-label="{{t "Search this site"}}" data-ghost-search>{{> "icons/search"}}</button>
|
||||
{{/match}}
|
||||
{{/unless}}
|
||||
</nav>
|
||||
|
||||
<div class="gh-head-actions">
|
||||
{{#unless @site.members_enabled}}
|
||||
{{^match @custom.navigation_layout "Stacked"}}
|
||||
<button class="gh-search gh-icon-btn" aria-label="{{t "Search this site"}}" data-ghost-search>{{> "icons/search"}}</button>
|
||||
{{/match}}
|
||||
{{else}}
|
||||
<button class="gh-search gh-icon-btn" aria-label="{{t "Search this site"}}" data-ghost-search>{{> "icons/search"}}</button>
|
||||
<div class="gh-head-members">
|
||||
{{#unless @member}}
|
||||
{{#unless @site.members_invite_only}}
|
||||
<a class="gh-head-link" href="#/portal/signin" data-portal="signin">{{t "Sign in"}}</a>
|
||||
<a class="gh-head-btn gh-btn gh-primary-btn" href="#/portal/signup" data-portal="signup">{{t "Subscribe"}}</a>
|
||||
{{else}}
|
||||
<a class="gh-head-btn gh-btn gh-primary-btn" href="#/portal/signin" data-portal="signin">{{t "Sign in"}}</a>
|
||||
{{/unless}}
|
||||
{{else}}
|
||||
<a class="gh-head-btn gh-btn gh-primary-btn" href="#/portal/account" data-portal="account">{{t "Account"}}</a>
|
||||
{{/unless}}
|
||||
</div>
|
||||
{{/unless}}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{{{body}}}
|
||||
|
||||
<footer class="gh-foot gh-outer">
|
||||
<div class="gh-foot-inner gh-inner">
|
||||
{{#if @site.members_enabled}}
|
||||
{{#unless @member}}
|
||||
<section class="gh-subscribe">
|
||||
<h3 class="gh-subscribe-title">{{t "Subscribe to {sitetitle}" sitetitle=@site.title}}</h3>
|
||||
{{#if @custom.email_signup_text}}
|
||||
<div class="gh-subscribe-description">{{@custom.email_signup_text}}</div>
|
||||
{{/if}}
|
||||
<button class="gh-subscribe-btn gh-btn" data-portal="signup">{{> "icons/email"}} {{t "Subscribe now"}}</button>
|
||||
</section>
|
||||
{{/unless}}
|
||||
{{/if}}
|
||||
|
||||
<nav class="gh-foot-menu">
|
||||
{{navigation type="secondary"}}
|
||||
</nav>
|
||||
|
||||
<div class="gh-social-links">
|
||||
{{#social_accounts @site}}
|
||||
<a href="{{href}}" target="_blank" rel="noopener" aria-label="{{name}}">
|
||||
{{#> (concat "icons/" type)}}
|
||||
{{!-- Fallback when no per-platform icon partial exists --}}
|
||||
<span>{{name}}</span>
|
||||
{{/undefined}}
|
||||
</a>
|
||||
{{/social_accounts}}
|
||||
</div>
|
||||
|
||||
<div class="gh-copyright">
|
||||
{{#unless @custom.footer_text}}
|
||||
{{@site.title}} © {{date format="YYYY"}}. {{{t "Powered by {ghostlink}" ghostlink="<a href=\"https://ghost.org/\" target=\"_blank\" rel=\"noopener\">Ghost</a>"}}}
|
||||
{{else}}
|
||||
{{@custom.footer_text}}
|
||||
{{/unless}}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
|
||||
{{#is "post, page"}}
|
||||
{{> "pswp"}}
|
||||
{{/is}}
|
||||
|
||||
<script src="{{asset "built/main.min.js"}}"></script>
|
||||
|
||||
{{ghost_foot}}
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{{!< default}}
|
||||
|
||||
<main class="gh-main gh-outer">
|
||||
<div class="gh-inner">
|
||||
{{#get "posts" filter="tag:hash-private-ops" include="tags,authors" limit="20" as |ops_posts|}}
|
||||
{{#foreach ops_posts limit="1"}}
|
||||
{{#if access}}
|
||||
<section class="gh-pagehead">
|
||||
<h1 class="gh-pagehead-title">Private Ops</h1>
|
||||
</section>
|
||||
|
||||
<div class="gh-topic gh-topic-grid">
|
||||
<div class="gh-topic-content gh-feed">
|
||||
{{#foreach ops_posts}}
|
||||
{{> "loop-grid" has_large_post=false}}
|
||||
{{/foreach}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<section class="gh-pagehead">
|
||||
<h1 class="gh-pagehead-title">No encontrado</h1>
|
||||
</section>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<section class="gh-pagehead">
|
||||
<h1 class="gh-pagehead-title">No encontrado</h1>
|
||||
</section>
|
||||
{{/foreach}}
|
||||
{{/get}}
|
||||
</div>
|
||||
</main>
|
||||
@@ -0,0 +1,89 @@
|
||||
{{#has tag="#private-ops"}}
|
||||
{{#if access}}
|
||||
<article class="gh-card {{post_class}}{{#match has_large_post "!=" false}}{{#has index="0"}} large{{/has}}{{/match}}">
|
||||
<a class="gh-card-link" href="{{url}}">
|
||||
{{#if feature_image}}
|
||||
<figure class="gh-card-image">
|
||||
<img
|
||||
srcset="{{img_url feature_image size="s"}} 300w,
|
||||
{{img_url feature_image size="m"}} 720w,
|
||||
{{img_url feature_image size="l"}} 960w,
|
||||
{{img_url feature_image size="xl"}} 1200w,
|
||||
{{img_url feature_image size="xxl"}} 2000w"
|
||||
sizes="(max-width: 1200px) 100vw, 1200px"
|
||||
src="{{img_url feature_image size="m"}}"
|
||||
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
|
||||
>
|
||||
</figure>
|
||||
{{/if}}
|
||||
|
||||
<div class="gh-card-wrapper">
|
||||
<header class="gh-card-header">
|
||||
<h3 class="gh-card-title">{{title}}</h3>
|
||||
</header>
|
||||
|
||||
{{#if excerpt}}
|
||||
{{#is "home"}}
|
||||
{{#has index="0"}}
|
||||
<div class="gh-card-excerpt">{{excerpt}}</div>
|
||||
{{/has}}
|
||||
{{else}}
|
||||
<div class="gh-card-excerpt">{{excerpt}}</div>
|
||||
{{/is}}
|
||||
{{/if}}
|
||||
|
||||
<footer class="gh-card-footer">
|
||||
<span class="gh-card-author">{{#foreach authors}}{{#if @first}}{{name}}{{else}}, {{name}}{{/if}}{{/foreach}}</span>
|
||||
<time class="gh-card-date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD MMM YYYY"}}</time>
|
||||
{{#if @site.comments_enabled}}
|
||||
{{comment_count class="gh-card-comments"}}
|
||||
{{/if}}
|
||||
</footer>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<article class="gh-card {{post_class}}{{#match has_large_post "!=" false}}{{#has index="0"}} large{{/has}}{{/match}}">
|
||||
<a class="gh-card-link" href="{{url}}">
|
||||
{{#if feature_image}}
|
||||
<figure class="gh-card-image">
|
||||
<img
|
||||
srcset="{{img_url feature_image size="s"}} 300w,
|
||||
{{img_url feature_image size="m"}} 720w,
|
||||
{{img_url feature_image size="l"}} 960w,
|
||||
{{img_url feature_image size="xl"}} 1200w,
|
||||
{{img_url feature_image size="xxl"}} 2000w"
|
||||
sizes="(max-width: 1200px) 100vw, 1200px"
|
||||
src="{{img_url feature_image size="m"}}"
|
||||
alt="{{#if feature_image_alt}}{{feature_image_alt}}{{else}}{{title}}{{/if}}"
|
||||
>
|
||||
</figure>
|
||||
{{/if}}
|
||||
|
||||
<div class="gh-card-wrapper">
|
||||
<header class="gh-card-header">
|
||||
<h3 class="gh-card-title">{{title}}</h3>
|
||||
</header>
|
||||
|
||||
{{#if excerpt}}
|
||||
{{#is "home"}}
|
||||
{{#has index="0"}}
|
||||
<div class="gh-card-excerpt">{{excerpt}}</div>
|
||||
{{/has}}
|
||||
{{else}}
|
||||
<div class="gh-card-excerpt">{{excerpt}}</div>
|
||||
{{/is}}
|
||||
{{/if}}
|
||||
|
||||
<footer class="gh-card-footer">
|
||||
<span class="gh-card-author">{{#foreach authors}}{{#if @first}}{{name}}{{else}}, {{name}}{{/if}}{{/foreach}}</span>
|
||||
<time class="gh-card-date" datetime="{{date format="YYYY-MM-DD"}}">{{date format="DD MMM YYYY"}}</time>
|
||||
{{#if @site.comments_enabled}}
|
||||
{{comment_count class="gh-card-comments"}}
|
||||
{{/if}}
|
||||
</footer>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{{/has}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{{#has tag="#private-ops"}}
|
||||
{{#if access}}
|
||||
<article class="gh-card {{post_class}}">
|
||||
<a class="gh-card-link" href="{{url}}">
|
||||
<h3 class="gh-card-title">{{title}}</h3>
|
||||
</a>
|
||||
</article>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<article class="gh-card {{post_class}}">
|
||||
<a class="gh-card-link" href="{{url}}">
|
||||
<h3 class="gh-card-title">{{title}}</h3>
|
||||
</a>
|
||||
</article>
|
||||
{{/has}}
|
||||
@@ -0,0 +1,83 @@
|
||||
{{!< default}}
|
||||
|
||||
<main class="gh-main">
|
||||
{{#post}}
|
||||
{{#has tag="#private-ops"}}
|
||||
{{#if access}}
|
||||
<article class="gh-article {{post_class}}">
|
||||
|
||||
<header class="gh-article-header gh-canvas">
|
||||
{{#if primary_tag}}
|
||||
<a class="gh-article-tag" href="{{primary_tag.url}}">{{primary_tag.name}}</a>
|
||||
{{/if}}
|
||||
|
||||
<h1 class="gh-article-title">{{title}}</h1>
|
||||
|
||||
{{#if custom_excerpt}}
|
||||
<p class="gh-article-excerpt">{{custom_excerpt}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{> "post-meta"}}
|
||||
|
||||
{{> "feature-image"}}
|
||||
</header>
|
||||
|
||||
<section class="gh-content gh-canvas">
|
||||
{{content}}
|
||||
</section>
|
||||
|
||||
</article>
|
||||
{{else}}
|
||||
<article class="gh-article">
|
||||
<header class="gh-article-header gh-canvas">
|
||||
<h1 class="gh-article-title">No encontrado</h1>
|
||||
</header>
|
||||
</article>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<article class="gh-article {{post_class}}">
|
||||
|
||||
<header class="gh-article-header gh-canvas">
|
||||
{{#if primary_tag}}
|
||||
<a class="gh-article-tag" href="{{primary_tag.url}}">{{primary_tag.name}}</a>
|
||||
{{/if}}
|
||||
|
||||
<h1 class="gh-article-title">{{title}}</h1>
|
||||
|
||||
{{#if custom_excerpt}}
|
||||
<p class="gh-article-excerpt">{{custom_excerpt}}</p>
|
||||
{{/if}}
|
||||
|
||||
{{> "post-meta"}}
|
||||
|
||||
{{> "feature-image"}}
|
||||
</header>
|
||||
|
||||
<section class="gh-content gh-canvas">
|
||||
{{content}}
|
||||
</section>
|
||||
|
||||
</article>
|
||||
{{/has}}
|
||||
{{/post}}
|
||||
|
||||
{{#post}}
|
||||
{{#has tag="#private-ops"}}
|
||||
{{#if access}}
|
||||
{{> "related-posts"}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{> "related-posts"}}
|
||||
{{/has}}
|
||||
{{/post}}
|
||||
|
||||
{{#post}}
|
||||
{{#has tag="#private-ops"}}
|
||||
{{#if access}}
|
||||
{{> "comments"}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{> "comments"}}
|
||||
{{/has}}
|
||||
{{/post}}
|
||||
</main>
|
||||
Reference in New Issue
Block a user