Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 48 additions & 4 deletions Jenkinsfile.deploy
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ pipeline {
timestamps()
}

parameters {
string(name: 'DB_MAIN_EU', defaultValue: '', description: 'The EU database to use for the main branch')
string(name: 'DB_MAIN_US_WEST', defaultValue: '', description: 'The US West database to use for the main branch')
string(name: 'DB_MAIN_US_EAST', defaultValue: '', description: 'The US East database to use for the main branch')
}

environment {
KV_DEV = credentials('cf-kv-dev')
KV_PROD = credentials('cf-kv-prod')
Expand Down Expand Up @@ -78,6 +84,27 @@ pipeline {
}
}
}
stage("Select master db") {
steps {
script {
env.USE_PARAMS_DB="false"
def setCount = 0
if (params.DB_MAIN_EU != "") setCount++
if (params.DB_MAIN_US_WEST != "") setCount++
if (params.DB_MAIN_US_EAST != "") setCount++
if (setCount > 0 && setCount < 3) {
error("Only some main db params are set. Please set all or none.")
}


if (setCount == 3) {
echo "Using params db"
env.USE_PARAMS_DB = "true"
}

}
}
}
Comment on lines +87 to +107
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Add strict validation to prevent shell injection and bad input.

Currently only count-based checks are done; values are neither trimmed nor validated. Whitelist DB names and fail fast.

Apply this diff inside the script block:

                 script {
                     env.USE_PARAMS_DB="false"                    
                     def setCount = 0
-                    if (params.DB_MAIN_EU != "") setCount++
-                    if (params.DB_MAIN_US_WEST != "") setCount++
-                    if (params.DB_MAIN_US_EAST != "") setCount++
+                    // Trim inputs to avoid accidental spaces being treated as values
+                    def P_DB_MAIN_EU = (params.DB_MAIN_EU ?: "").trim()
+                    def P_DB_MAIN_US_WEST = (params.DB_MAIN_US_WEST ?: "").trim()
+                    def P_DB_MAIN_US_EAST = (params.DB_MAIN_US_EAST ?: "").trim()
+                    if (P_DB_MAIN_EU != "") setCount++
+                    if (P_DB_MAIN_US_WEST != "") setCount++
+                    if (P_DB_MAIN_US_EAST != "") setCount++
                     if (setCount > 0 && setCount < 3) {
                         error("Only some main db params are set. Please set all or none.")
                     }
 
-                    
-                    
                     if (setCount == 3) { 
-                        echo "Using params db"
+                        // Whitelist allowed DB name characters to avoid shell injection
+                        def valid = ~/^[A-Za-z0-9._-]+$/
+                        [P_DB_MAIN_EU, P_DB_MAIN_US_WEST, P_DB_MAIN_US_EAST].each { v ->
+                            if (!(v ==~ valid)) {
+                                error("Invalid DB name '${v}'. Only A-Z, a-z, 0-9, '.', '_', '-' are allowed.")
+                            }
+                        }
+                        // Persist validated, trimmed values into env for later use
+                        env.DB_MAIN_EU = P_DB_MAIN_EU
+                        env.DB_MAIN_US_WEST = P_DB_MAIN_US_WEST
+                        env.DB_MAIN_US_EAST = P_DB_MAIN_US_EAST
+                        echo "Using params db"
                         env.USE_PARAMS_DB = "true"
                     }
 
                 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
stage("Select master db") {
steps {
script {
env.USE_PARAMS_DB="false"
def setCount = 0
if (params.DB_MAIN_EU != "") setCount++
if (params.DB_MAIN_US_WEST != "") setCount++
if (params.DB_MAIN_US_EAST != "") setCount++
if (setCount > 0 && setCount < 3) {
error("Only some main db params are set. Please set all or none.")
}
if (setCount == 3) {
echo "Using params db"
env.USE_PARAMS_DB = "true"
}
}
}
}
stage("Select master db") {
steps {
script {
env.USE_PARAMS_DB="false"
def setCount = 0
// Trim inputs to avoid accidental spaces being treated as values
def P_DB_MAIN_EU = (params.DB_MAIN_EU ?: "").trim()
def P_DB_MAIN_US_WEST = (params.DB_MAIN_US_WEST ?: "").trim()
def P_DB_MAIN_US_EAST = (params.DB_MAIN_US_EAST ?: "").trim()
if (P_DB_MAIN_EU != "") setCount++
if (P_DB_MAIN_US_WEST != "") setCount++
if (P_DB_MAIN_US_EAST != "") setCount++
if (setCount > 0 && setCount < 3) {
error("Only some main db params are set. Please set all or none.")
}
if (setCount == 3) {
// Whitelist allowed DB name characters to avoid shell injection
def valid = ~/^[A-Za-z0-9._-]+$/
[P_DB_MAIN_EU, P_DB_MAIN_US_WEST, P_DB_MAIN_US_EAST].each { v ->
if (!(v ==~ valid)) {
error("Invalid DB name '${v}'. Only A-Z, a-z, 0-9, '.', '_', '-' are allowed.")
}
}
// Persist validated, trimmed values into env for later use
env.DB_MAIN_EU = P_DB_MAIN_EU
env.DB_MAIN_US_WEST = P_DB_MAIN_US_WEST
env.DB_MAIN_US_EAST = P_DB_MAIN_US_EAST
echo "Using params db"
env.USE_PARAMS_DB = "true"
}
}
}
}
🤖 Prompt for AI Agents
In Jenkinsfile.deploy around lines 87 to 107, the current master DB selection
only counts params and does not trim or validate values, leaving the pipeline
vulnerable to bad input or shell injection; update the script block to trim each
DB param, verify all three are either empty or present, and validate each
non-empty value against a strict whitelist (or a tight regex allowing only
expected DB name patterns/characters and max length); if any value fails
validation or only some are set, call error(...) with a clear message and do not
set USE_PARAMS_DB, otherwise set USE_PARAMS_DB="true" after validation passes.

stage("Branch db") {
steps {
script {
Expand All @@ -103,12 +130,29 @@ pipeline {
error("DB branch name is too long")
}
withCredentials([string(credentialsId: 'tursor_api_token', variable: 'TURSO_API_TOKEN')]) {
echo "prams status $USE_PARAMS_DB"
if (env.USE_PARAMS_DB == "true") {
echo "Using params db"
sh """
turso org switch angular-love
turso db create eu-${DB_BRANCH_NAME} --from-db $DB_MAIN_EU --group blog-eu
turso db create usw-${DB_BRANCH_NAME} --from-db $DB_MAIN_US_WEST --group blog-us-west
turso db create use-${DB_BRANCH_NAME} --from-db $DB_MAIN_US_EAST --group blog-us-east
"""
} else {
echo "Using normal db"
sh """
turso org switch angular-love
turso db create eu-${DB_BRANCH_NAME} --from-db $TURSO_EU_DB --group blog-eu
turso db create usw-${DB_BRANCH_NAME} --from-db $TURSO_US_WEST_DB --group blog-us-west
turso db create use-${DB_BRANCH_NAME} --from-db $TURSO_US_EAST_DB --group blog-us-east
"""

}

Comment on lines +133 to +152
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Quote variables and export correct Turso token to harden shell blocks.

User-provided params are interpolated unquoted in sh; also ensure CLI gets the right token. Quote all vars and export TURSO_AUTH_TOKEN.

Apply this diff:

-                    echo "prams status $USE_PARAMS_DB"
+                    echo "params status: $USE_PARAMS_DB"
                     if (env.USE_PARAMS_DB == "true") {
                         echo "Using params db"
                         sh """
-                            turso org switch angular-love
-                            turso db create eu-${DB_BRANCH_NAME} --from-db $DB_MAIN_EU --group blog-eu
-                            turso db create usw-${DB_BRANCH_NAME} --from-db $DB_MAIN_US_WEST --group blog-us-west
-                            turso db create use-${DB_BRANCH_NAME} --from-db $DB_MAIN_US_EAST --group blog-us-east
+                            set -eu
+                            export TURSO_AUTH_TOKEN="\${TURSO_API_TOKEN}"
+                            turso org switch angular-love
+                            turso db create "eu-${DB_BRANCH_NAME}" --from-db "\${DB_MAIN_EU}" --group blog-eu
+                            turso db create "usw-${DB_BRANCH_NAME}" --from-db "\${DB_MAIN_US_WEST}" --group blog-us-west
+                            turso db create "use-${DB_BRANCH_NAME}" --from-db "\${DB_MAIN_US_EAST}" --group blog-us-east
                         """
                     } else {
                         echo "Using normal db"
                         sh """
-                            turso org switch angular-love
-                            turso db create eu-${DB_BRANCH_NAME} --from-db $TURSO_EU_DB --group blog-eu
-                            turso db create usw-${DB_BRANCH_NAME} --from-db $TURSO_US_WEST_DB --group blog-us-west
-                            turso db create use-${DB_BRANCH_NAME} --from-db $TURSO_US_EAST_DB --group blog-us-east
+                            set -eu
+                            export TURSO_AUTH_TOKEN="\${TURSO_API_TOKEN}"
+                            turso org switch angular-love
+                            turso db create "eu-${DB_BRANCH_NAME}" --from-db "\${TURSO_EU_DB}" --group blog-eu
+                            turso db create "usw-${DB_BRANCH_NAME}" --from-db "\${TURSO_US_WEST_DB}" --group blog-us-west
+                            turso db create "use-${DB_BRANCH_NAME}" --from-db "\${TURSO_US_EAST_DB}" --group blog-us-east
                         """
                         
                     }

To confirm the correct env var name used by the Turso CLI, run a quick web check:


🌐 Web query:

What environment variable does the Turso CLI use for authentication? Is it TURSO_AUTH_TOKEN or TURSO_API_TOKEN?

💡 Result:

The Turso CLI / SDK examples use TURSO_AUTH_TOKEN for database authentication. TURSO_API_TOKEN is used for platform/organization API tokens in some integrations. [1][2]

Sources:
[1] Turso CLI / SDK quickstarts (shows TURSO_AUTH_TOKEN).
[2] Turso docs on API tokens / platform usage (references TURSO_API_TOKEN for platform-level tokens).


Enable strict shell mode, quote variables, export Turso CLI auth token, and fix typo
Add set -eu and export TURSO_AUTH_TOKEN="$TURSO_API_TOKEN" at the top of each sh block, wrap all ${…} expansions in quotes, and correct echo "prams status…" → "params status: $USE_PARAMS_DB".

🤖 Prompt for AI Agents
In Jenkinsfile.deploy around lines 133 to 152, fix the shell blocks to enable
strict shell mode, export the Turso CLI auth token, quote all variable
expansions, and correct the echo typo: add a leading `set -eu` and `export
TURSO_AUTH_TOKEN="$TURSO_API_TOKEN"` at the top of each multi-line sh block,
change `echo "prams status $USE_PARAMS_DB"` to `echo "params status:
$USE_PARAMS_DB"`, and update all turso invocations and other expansions to use
quoted variables (e.g. "$DB_BRANCH_NAME", "$DB_MAIN_EU", "$TURSO_EU_DB",
"$USE_PARAMS_DB", etc.) so the shells fail fast on errors and handle
spaces/special chars safely.


sh """
turso org switch angular-love
turso db create eu-${DB_BRANCH_NAME} --from-db $TURSO_EU_DB --group blog-eu
turso db create usw-${DB_BRANCH_NAME} --from-db $TURSO_US_WEST_DB --group blog-us-west
turso db create use-${DB_BRANCH_NAME} --from-db $TURSO_US_EAST_DB --group blog-us-east

turso db list -g blog-eu | grep -q "eu-${DB_BRANCH_NAME}" || { echo "EU database not found after creation"; exit 1; }
turso db list -g blog-us-west | grep -q "usw-${DB_BRANCH_NAME}" || { echo "US West database not found after creation"; exit 1; }
turso db list -g blog-us-east | grep -q "use-${DB_BRANCH_NAME}" || { echo "US East database not found after creation"; exit 1; }
Expand Down
2 changes: 1 addition & 1 deletion apps/blog/scripts/build-routes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const constructUrl = (path, lang) => `/${lang}/${path}`;
* @returns {Promise<void>}
*/
async function fetchArticleRoutes(lang, skip = 0, take = 50) {
const url = `${API_BASE_URL}/articles?skip=${skip}&take=${take}`;
const url = `${API_BASE_URL}/articles?skip=${skip}&take=${take}&showHidden`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify intent: SSG now includes hidden articles.

Adding showHidden to the articles API call means all hidden articles will have pre-rendered static routes. Ensure this is intentional and that hidden articles are protected by runtime access controls (e.g., authentication, server-side checks) if they should not be publicly accessible.

Run the following script to check if hidden articles have runtime access controls:


🏁 Script executed:

#!/bin/bash
# Description: Check if article detail route or API has access control for hidden articles.

# Search for guards, middleware, or access checks in article detail routes/components
rg -nP --type=ts -C3 '\b(canActivate|guard|isHidden|hidden)\b' \
  -g 'apps/blog/**' \
  -g 'libs/blog/**' \
  -g '!**/*.spec.ts'

Length of output: 41824


Prevent exposing hidden articles in SSG
Unconditionally appending &showHidden in apps/blog/scripts/build-routes.mjs pre-renders and serves hidden articles with no runtime guard. Remove or conditionalize this flag or implement server‐side checks to restrict hidden content.

🤖 Prompt for AI Agents
In apps/blog/scripts/build-routes.mjs around line 50, the URL unconditionally
appends "&showHidden" which causes static generation to include hidden articles;
remove the hardcoded flag or make it conditional and ensure the API enforces
access control. Replace the literal "&showHidden" with either nothing or a safe
conditional check (e.g. only append when a trusted env var like
PRERENDER_INCLUDE_HIDDEN is true) so SSG won't fetch hidden content by default,
and/or update the backend endpoint to require authentication/authorization for
showHidden queries so hidden articles cannot be returned to unauthenticated SSG
requests.

try {
const { data, total } = await fetch(url, {
headers: {
Expand Down
2 changes: 1 addition & 1 deletion apps/blog/src/assets/icons/arrow-down.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/blog/src/assets/icons/moon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/blog/src/assets/icons/sun.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions libs/blog-bff/articles/api/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ app.get('/', async (c) => {
featuredImageUrl: articles.imageUrl,
readingTime: articles.readingTime,
publishDate: articles.publishDate,
hidden: articles.publishDate,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: incorrect field mapping for hidden.

Line 41 maps hidden to articles.publishDate instead of articles.isHidden. This will expose publishDate as hidden in the API response, which is incorrect.

Apply this diff to fix the mapping:

-      hidden: articles.publishDate,
+      hidden: articles.isHidden,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
hidden: articles.publishDate,
hidden: articles.isHidden,
🤖 Prompt for AI Agents
In libs/blog-bff/articles/api/src/lib/api.ts around line 41, the mapping
mistakenly sets hidden: articles.publishDate; change it to hidden:
articles.isHidden so the API returns the correct boolean flag; update that
single property mapping to reference articles.isHidden.

author: {
slug: authors.slug,
name: authors.name,
Expand All @@ -50,6 +51,7 @@ app.get('/', async (c) => {
and(
eq(articles.status, ArticleStatus.Publish),
eq(articles.language, dbLangMap[c.var.lang]),
...showHiddenFilter(articles, queryParams.showHidden),
...withCategoryFilters(articles, queryParams.category),
),
)
Expand All @@ -64,6 +66,7 @@ app.get('/', async (c) => {
and(
eq(articleCounts.lang, dbLangMap[c.var.lang]),
eq(articleCounts.status, ArticleStatus.Publish),
...showHiddenFilter(articleCounts, queryParams.showHidden),
...withCategoryFilters(articleCounts, queryParams.category),
),
)
Expand Down Expand Up @@ -140,6 +143,13 @@ app.get('/:id/related', async (c) => {

export default app;

function showHiddenFilter(
table: typeof articles | typeof articleCounts,
showHidden?: string,
) {
return showHidden !== undefined ? [] : [eq(table.isHidden, false)];
}

function withCategoryFilters(
table: typeof articles | typeof articleCounts,
category?: string,
Expand Down
24 changes: 0 additions & 24 deletions libs/blog-bff/newsletter/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,16 @@ app.post('/subscribe', async (c) => {
const { BREVO_API_KEY, BREVO_API_URL } = env(c);

let listIds: number[];
let templateId: number;

if (lang === 'pl') {
listIds = [NewsletterList.PL, NewsletterList.PLNew];
templateId = NewsletterTemplate.PL;
} else {
listIds = [NewsletterList.EN];
templateId = NewsletterTemplate.EN;
}

try {
const parsedEmail = v.parse(EmailSchema, newSubscriber);
const client = new NewsletterClient(BREVO_API_URL, BREVO_API_KEY);
let sendTemplate = true;

try {
const existingContact = await client.getContact(parsedEmail);
Expand All @@ -75,9 +71,6 @@ app.post('/subscribe', async (c) => {
listIds: mergedListIds,
});
}

// Contact is already on the list, we should not send a welcoming template
sendTemplate = !alreadySubscribed;
} catch (err) {
if (
typeof err === 'object' &&
Expand All @@ -94,23 +87,6 @@ app.post('/subscribe', async (c) => {
}
}

if (sendTemplate) {
const template = await client.getTemplate(templateId);

await client.sendEmail({
sender: {
id: template.sender.id,
},
subject: template.subject,
htmlContent: template.htmlContent,
to: [
{
email: parsedEmail,
},
],
});
}

return c.json({ success: true }, 200);
} catch (e) {
if (e instanceof v.ValiError) {
Expand Down
11 changes: 0 additions & 11 deletions libs/blog-bff/newsletter/src/lib/newsletter-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,6 @@ export class NewsletterClient {
private _apiKey: string,
) {}

getTemplate(templateId: number): Promise<Template> {
return this.request<Template>(`smtp/templates/${templateId}`);
}

sendEmail(dto: SendEmailDto): Promise<void> {
return this.request<void>(`smtp/email`, {
method: 'POST',
body: dto,
});
}

createContact(contact: NewContactDto): Promise<void> {
return this.request(`contacts`, {
method: 'POST',
Expand Down
9 changes: 9 additions & 0 deletions libs/blog-bff/shared/schema/src/lib/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const articles = sqliteTable(
.$type<ArticleTranslation[]>()
.notNull(),
seo: text('seo', { mode: 'json' }).$type<SeoData>(),
isHidden: integer('is_hidden', { mode: 'boolean' }).notNull(),
categories: text('categories', { mode: 'json' })
.notNull()
.$type<string[]>(),
Expand All @@ -100,36 +101,42 @@ export const articles = sqliteTable(
uniqueIndex('article_slug_idx').on(table.slug),
index('article_guide_covering_idx').on(
table.status,
table.isHidden,
table.language,
table.isGuide,
table.publishDate,
),
index('article_recommended_covering_idx').on(
table.status,
table.isHidden,
table.language,
table.isRecommended,
table.publishDate,
),
index('article_news_covering_idx').on(
table.status,
table.isHidden,
table.language,
table.isNews,
table.publishDate,
),
index('article_in_depth_covering_idx').on(
table.status,
table.isHidden,
table.language,
table.isInDepth,
table.publishDate,
),
index('article_covering_idx').on(
table.status,
table.isHidden,
table.language,
table.publishDate,
),
index('article_author_covering_idx').on(
table.authorId,
table.status,
table.isHidden,
table.language,
table.publishDate,
),
Expand All @@ -145,6 +152,7 @@ export const articleCounts = sqliteTable(
isGuide: integer('is_guide', { mode: 'boolean' }).notNull(),
isInDepth: integer('is_in_depth', { mode: 'boolean' }).notNull(),
isRecommended: integer('is_recommended', { mode: 'boolean' }).notNull(),
isHidden: integer('is_hidden', { mode: 'boolean' }).notNull(),
rowCount: integer('row_count').notNull(),
},
(table) => [
Expand All @@ -155,6 +163,7 @@ export const articleCounts = sqliteTable(
table.isGuide,
table.isInDepth,
table.isRecommended,
table.isHidden,
),
],
);
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<ng-container *transloco="let t; read: 'aboutUsPage'">
<h2 class="py-4 text-[40px] font-bold">
<h2 class="text-al-primary-foreground py-4 text-[40px] font-bold">
{{ t('title') }}
</h2>

<al-card>
<al-card class="bg-transparent">
<section
alCardContent
aria-labelledby="angular-love"
Expand Down Expand Up @@ -35,13 +35,18 @@ <h2 class="py-4 text-[40px] font-bold">
<al-newsletter alCardContent />
</al-card>

<h2 class="mb-8 mt-10 text-[40px] font-bold">
<h2 class="text-al-primary-foreground mb-8 mt-10 text-[40px] font-bold">
{{ t('authorsTitle') }}
</h2>
</ng-container>

@for (author of authorsCards(); track author.slug) {
<al-author-card class="mb-6 block" [author]="author" [linkable]="true" />
<al-author-card
class="mb-6 block"
[author]="author"
[linkable]="true"
[hideGradient]="hideGradientInAuthorCards()"
/>

@if ($index === noAuthorsInView() - 2) {
@defer (on viewport) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from '@angular-love/blog/shared/ui-card';
import { InfiniteScrollTriggerDirective } from '@angular-love/blog/shared/ui-pagination';
import { SocialMediaIconsComponent } from '@angular-love/blog/shared/ui-social-media-icons';
import { AppThemeStore } from '@angular-love/data-access-app-theme';

@Component({
selector: 'al-about-us',
Expand All @@ -41,6 +42,10 @@ export class FeatureAboutUsComponent implements OnInit {
return this.authorsCards()?.length || 0;
});

readonly theme = inject(AppThemeStore).theme;

readonly hideGradientInAuthorCards = computed(() => this.theme() === 'light');

private readonly _skip = this._authorListStore.skip;
private readonly _total = this._authorListStore.total;
private readonly _pageSize = this._authorListStore.pageSize;
Expand Down
37 changes: 24 additions & 13 deletions libs/blog/app-theme/data-access-app-theme/src/app-theme.store.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { isPlatformBrowser } from '@angular/common';
import { inject, Injectable, PLATFORM_ID } from '@angular/core';
import { signalStore, withMethods, withState } from '@ngrx/signals';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';

type Theme = 'dark' | 'light';
export type Theme = 'dark' | 'light';

interface AppThemeStore {
theme: Theme;
}

export const AppThemeStore = signalStore(
{ providedIn: 'root' },
withState<AppThemeStore>({ theme: 'light' }),
withState<AppThemeStore>({ theme: 'dark' }),
withMethods(
(
store,
Expand All @@ -19,7 +19,18 @@ export const AppThemeStore = signalStore(
) => ({
syncWithSystemTheme: () => {
if (isPlatformBrowser(platformId)) {
ccConsumer.setThemeClass(getSystemTheme());
const theme =
(localStorage.getItem('theme') as Theme) ?? getSystemTheme();
ccConsumer.setThemeAttribute(theme);
patchState(store, { theme: theme });
}
},
toggleTheme: () => {
if (isPlatformBrowser(platformId)) {
const newTheme = store.theme() === 'dark' ? 'light' : 'dark';
ccConsumer.setThemeAttribute(newTheme);
localStorage.setItem('theme', newTheme);
patchState(store, { theme: newTheme });
}
},
}),
Expand All @@ -35,15 +46,15 @@ function getSystemTheme(): Theme {
/* todo: create consumer interface and decouple AppThemeStore from CCAppThemeConsumer*/
@Injectable({ providedIn: 'root' })
export class CCAppThemeConsumer {
setThemeClass(theme: Theme): void {
const htmlElement = document.documentElement;
switch (theme) {
case 'dark':
htmlElement.classList.add('cc--darkmode');
break;
case 'light':
htmlElement.classList.remove('cc--darkmode');
break;
setThemeAttribute(theme: Theme): void {
document.documentElement.setAttribute('data-theme', theme);

const classList = document.documentElement.classList;

if (theme === 'dark') {
classList.add('cc--darkmode');
} else {
classList.remove('cc--darkmode');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ <h1 id="article-title" class="flex text-[40px] font-bold">
</section>
<aside class="order-3 col-span-12 lg:col-span-4">
<al-author-card
[articleCard]="true"
[author]="articleDetails().author"
[clampText]="true"
[linkable]="true"
Expand Down
Loading