Refactor project structure and update files

This commit is contained in:
Daniel LaForce 2025-04-23 01:48:18 -06:00
parent 175a29b4fa
commit 472e57d2f0
124 changed files with 22896 additions and 3385 deletions

View File

@ -0,0 +1,130 @@
{
"$ref": "#/definitions/blog",
"definitions": {
"blog": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"pubDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "null"
}
]
},
"updatedDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "null"
}
]
},
"heroImage": {
"anyOf": [
{
"anyOf": [
{
"not": {}
},
{
"type": "string"
}
]
},
{
"type": "null"
}
]
},
"category": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"draft": {
"type": "boolean"
},
"readTime": {
"type": "string"
},
"image": {
"type": "string"
},
"excerpt": {
"type": "string"
},
"author": {
"type": "string"
},
"github": {
"type": "string"
},
"live": {
"type": "string"
},
"technologies": {
"type": "array",
"items": {
"type": "string"
}
},
"$schema": {
"type": "string"
}
},
"required": [
"title",
"pubDate"
],
"additionalProperties": true
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@ -0,0 +1,130 @@
{
"$ref": "#/definitions/configurations",
"definitions": {
"configurations": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"pubDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "null"
}
]
},
"updatedDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "null"
}
]
},
"heroImage": {
"anyOf": [
{
"anyOf": [
{
"not": {}
},
{
"type": "string"
}
]
},
{
"type": "null"
}
]
},
"category": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"draft": {
"type": "boolean"
},
"readTime": {
"type": "string"
},
"image": {
"type": "string"
},
"excerpt": {
"type": "string"
},
"author": {
"type": "string"
},
"github": {
"type": "string"
},
"live": {
"type": "string"
},
"technologies": {
"type": "array",
"items": {
"type": "string"
}
},
"$schema": {
"type": "string"
}
},
"required": [
"title",
"pubDate"
],
"additionalProperties": true
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@ -0,0 +1,113 @@
{
"$ref": "#/definitions/external-posts",
"definitions": {
"external-posts": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"pubDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
}
]
},
"updatedDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
}
]
},
"heroImage": {
"type": "string"
},
"category": {
"type": "string",
"default": "Uncategorized"
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"draft": {
"type": "boolean",
"default": false
},
"readTime": {
"type": "string"
},
"image": {
"type": "string"
},
"excerpt": {
"type": "string"
},
"author": {
"type": "string"
},
"github": {
"type": "string"
},
"live": {
"type": "string"
},
"technologies": {
"type": "array",
"items": {
"type": "string"
}
},
"$schema": {
"type": "string"
}
},
"required": [
"title",
"pubDate"
],
"additionalProperties": false
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@ -0,0 +1,130 @@
{
"$ref": "#/definitions/posts",
"definitions": {
"posts": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"pubDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "null"
}
]
},
"updatedDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "null"
}
]
},
"heroImage": {
"anyOf": [
{
"anyOf": [
{
"not": {}
},
{
"type": "string"
}
]
},
{
"type": "null"
}
]
},
"category": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"draft": {
"type": "boolean"
},
"readTime": {
"type": "string"
},
"image": {
"type": "string"
},
"excerpt": {
"type": "string"
},
"author": {
"type": "string"
},
"github": {
"type": "string"
},
"live": {
"type": "string"
},
"technologies": {
"type": "array",
"items": {
"type": "string"
}
},
"$schema": {
"type": "string"
}
},
"required": [
"title",
"pubDate"
],
"additionalProperties": true
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@ -0,0 +1,130 @@
{
"$ref": "#/definitions/projects",
"definitions": {
"projects": {
"type": "object",
"properties": {
"title": {
"type": "string"
},
"description": {
"type": "string"
},
"pubDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "null"
}
]
},
"updatedDate": {
"anyOf": [
{
"type": "string"
},
{
"anyOf": [
{
"type": "string",
"format": "date-time"
},
{
"type": "string",
"format": "date"
},
{
"type": "integer",
"format": "unix-time"
}
]
},
{
"type": "null"
}
]
},
"heroImage": {
"anyOf": [
{
"anyOf": [
{
"not": {}
},
{
"type": "string"
}
]
},
{
"type": "null"
}
]
},
"category": {
"type": "string"
},
"tags": {
"type": "array",
"items": {
"type": "string"
}
},
"draft": {
"type": "boolean"
},
"readTime": {
"type": "string"
},
"image": {
"type": "string"
},
"excerpt": {
"type": "string"
},
"author": {
"type": "string"
},
"github": {
"type": "string"
},
"live": {
"type": "string"
},
"technologies": {
"type": "array",
"items": {
"type": "string"
}
},
"$schema": {
"type": "string"
}
},
"required": [
"title",
"pubDate"
],
"additionalProperties": true
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}

View File

@ -0,0 +1 @@
export default new Map();

View File

@ -0,0 +1 @@
export default new Map();

207
.astro/content.d.ts vendored Normal file
View File

@ -0,0 +1,207 @@
declare module 'astro:content' {
interface Render {
'.mdx': Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
components: import('astro').MDXInstance<{}>['components'];
}>;
}
}
declare module 'astro:content' {
export interface RenderResult {
Content: import('astro/runtime/server/index.js').AstroComponentFactory;
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}
interface Render {
'.md': Promise<RenderResult>;
}
export interface RenderedContent {
html: string;
metadata?: {
imagePaths: Array<string>;
[key: string]: unknown;
};
}
}
declare module 'astro:content' {
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof AnyEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
export type ContentCollectionKey = keyof ContentEntryMap;
export type DataCollectionKey = keyof DataEntryMap;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
ContentEntryMap[C]
>['slug'];
export type ReferenceDataEntry<
C extends CollectionKey,
E extends keyof DataEntryMap[C] = string,
> = {
collection: C;
id: E;
};
export type ReferenceContentEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}) = string,
> = {
collection: C;
slug: E;
};
/** @deprecated Use `getEntry` instead. */
export function getEntryBySlug<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
// Note that this has to accept a regular string too, for SSR
entrySlug: E,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
/** @deprecated Use `getEntry` instead. */
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
collection: C,
entryId: E,
): Promise<CollectionEntry<C>>;
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E,
): Promise<E[]>;
export function getCollection<C extends keyof AnyEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown,
): Promise<CollectionEntry<C>[]>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
entry: ReferenceContentEntry<C, E>,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
entry: ReferenceDataEntry<C, E>,
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
slug: E,
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E,
): E extends keyof DataEntryMap[C]
? string extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]> | undefined
: Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof ContentEntryMap>(
entries: ReferenceContentEntry<C, ValidContentEntrySlug<C>>[],
): Promise<CollectionEntry<C>[]>;
export function getEntries<C extends keyof DataEntryMap>(
entries: ReferenceDataEntry<C, keyof DataEntryMap[C]>[],
): Promise<CollectionEntry<C>[]>;
export function render<C extends keyof AnyEntryMap>(
entry: AnyEntryMap[C][string],
): Promise<RenderResult>;
export function reference<C extends keyof AnyEntryMap>(
collection: C,
): import('astro/zod').ZodEffects<
import('astro/zod').ZodString,
C extends keyof ContentEntryMap
? ReferenceContentEntry<C, ValidContentEntrySlug<C>>
: ReferenceDataEntry<C, keyof DataEntryMap[C]>
>;
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
export function reference<C extends string>(
collection: C,
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ContentEntryMap = {
};
type DataEntryMap = {
"blog": Record<string, {
id: string;
render(): Render[".md"];
slug: string;
body: string;
collection: "blog";
data: InferEntrySchema<"blog">;
rendered?: RenderedContent;
filePath?: string;
}>;
"configurations": Record<string, {
id: string;
render(): Render[".md"];
slug: string;
body: string;
collection: "configurations";
data: InferEntrySchema<"configurations">;
rendered?: RenderedContent;
filePath?: string;
}>;
"posts": Record<string, {
id: string;
render(): Render[".md"];
slug: string;
body: string;
collection: "posts";
data: InferEntrySchema<"posts">;
rendered?: RenderedContent;
filePath?: string;
}>;
"projects": Record<string, {
id: string;
render(): Render[".md"];
slug: string;
body: string;
collection: "projects";
data: InferEntrySchema<"projects">;
rendered?: RenderedContent;
filePath?: string;
}>;
};
type AnyEntryMap = ContentEntryMap & DataEntryMap;
export type ContentConfig = typeof import("../src/content/config.js");
}

1
.astro/data-store.json Normal file

File diff suppressed because one or more lines are too long

5
.astro/settings.json Normal file
View File

@ -0,0 +1,5 @@
{
"_variables": {
"lastUpdateCheck": 1745122883007
}
}

329
.astro/types.d.ts vendored Normal file
View File

@ -0,0 +1,329 @@
declare module 'astro:content' {
interface Render {
'.mdx': Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}>;
}
}
declare module 'astro:content' {
interface Render {
'.md': Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
remarkPluginFrontmatter: Record<string, any>;
}>;
}
}
declare module 'astro:content' {
export { z } from 'astro/zod';
type Flatten<T> = T extends { [K: string]: infer U } ? U : never;
export type CollectionKey = keyof AnyEntryMap;
export type CollectionEntry<C extends CollectionKey> = Flatten<AnyEntryMap[C]>;
export type ContentCollectionKey = keyof ContentEntryMap;
export type DataCollectionKey = keyof DataEntryMap;
// This needs to be in sync with ImageMetadata
export type ImageFunction = () => import('astro/zod').ZodObject<{
src: import('astro/zod').ZodString;
width: import('astro/zod').ZodNumber;
height: import('astro/zod').ZodNumber;
format: import('astro/zod').ZodUnion<
[
import('astro/zod').ZodLiteral<'png'>,
import('astro/zod').ZodLiteral<'jpg'>,
import('astro/zod').ZodLiteral<'jpeg'>,
import('astro/zod').ZodLiteral<'tiff'>,
import('astro/zod').ZodLiteral<'webp'>,
import('astro/zod').ZodLiteral<'gif'>,
import('astro/zod').ZodLiteral<'svg'>,
import('astro/zod').ZodLiteral<'avif'>,
]
>;
}>;
type BaseSchemaWithoutEffects =
| import('astro/zod').AnyZodObject
| import('astro/zod').ZodUnion<[BaseSchemaWithoutEffects, ...BaseSchemaWithoutEffects[]]>
| import('astro/zod').ZodDiscriminatedUnion<string, import('astro/zod').AnyZodObject[]>
| import('astro/zod').ZodIntersection<BaseSchemaWithoutEffects, BaseSchemaWithoutEffects>;
type BaseSchema =
| BaseSchemaWithoutEffects
| import('astro/zod').ZodEffects<BaseSchemaWithoutEffects>;
export type SchemaContext = { image: ImageFunction };
type DataCollectionConfig<S extends BaseSchema> = {
type: 'data';
schema?: S | ((context: SchemaContext) => S);
};
type ContentCollectionConfig<S extends BaseSchema> = {
type?: 'content';
schema?: S | ((context: SchemaContext) => S);
};
type CollectionConfig<S> = ContentCollectionConfig<S> | DataCollectionConfig<S>;
export function defineCollection<S extends BaseSchema>(
input: CollectionConfig<S>
): CollectionConfig<S>;
type AllValuesOf<T> = T extends any ? T[keyof T] : never;
type ValidContentEntrySlug<C extends keyof ContentEntryMap> = AllValuesOf<
ContentEntryMap[C]
>['slug'];
export function getEntryBySlug<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
// Note that this has to accept a regular string too, for SSR
entrySlug: E
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getDataEntryById<C extends keyof DataEntryMap, E extends keyof DataEntryMap[C]>(
collection: C,
entryId: E
): Promise<CollectionEntry<C>>;
export function getCollection<C extends keyof AnyEntryMap, E extends CollectionEntry<C>>(
collection: C,
filter?: (entry: CollectionEntry<C>) => entry is E
): Promise<E[]>;
export function getCollection<C extends keyof AnyEntryMap>(
collection: C,
filter?: (entry: CollectionEntry<C>) => unknown
): Promise<CollectionEntry<C>[]>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(entry: {
collection: C;
slug: E;
}): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(entry: {
collection: C;
id: E;
}): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof ContentEntryMap,
E extends ValidContentEntrySlug<C> | (string & {}),
>(
collection: C,
slug: E
): E extends ValidContentEntrySlug<C>
? Promise<CollectionEntry<C>>
: Promise<CollectionEntry<C> | undefined>;
export function getEntry<
C extends keyof DataEntryMap,
E extends keyof DataEntryMap[C] | (string & {}),
>(
collection: C,
id: E
): E extends keyof DataEntryMap[C]
? Promise<DataEntryMap[C][E]>
: Promise<CollectionEntry<C> | undefined>;
/** Resolve an array of entry references from the same collection */
export function getEntries<C extends keyof ContentEntryMap>(
entries: {
collection: C;
slug: ValidContentEntrySlug<C>;
}[]
): Promise<CollectionEntry<C>[]>;
export function getEntries<C extends keyof DataEntryMap>(
entries: {
collection: C;
id: keyof DataEntryMap[C];
}[]
): Promise<CollectionEntry<C>[]>;
export function reference<C extends keyof AnyEntryMap>(
collection: C
): import('astro/zod').ZodEffects<
import('astro/zod').ZodString,
C extends keyof ContentEntryMap
? {
collection: C;
slug: ValidContentEntrySlug<C>;
}
: {
collection: C;
id: keyof DataEntryMap[C];
}
>;
// Allow generic `string` to avoid excessive type errors in the config
// if `dev` is not running to update as you edit.
// Invalid collection names will be caught at build time.
export function reference<C extends string>(
collection: C
): import('astro/zod').ZodEffects<import('astro/zod').ZodString, never>;
type ReturnTypeOrOriginal<T> = T extends (...args: any[]) => infer R ? R : T;
type InferEntrySchema<C extends keyof AnyEntryMap> = import('astro/zod').infer<
ReturnTypeOrOriginal<Required<ContentConfig['collections'][C]>['schema']>
>;
type ContentEntryMap = {
"configurations": {
"git-symlinks-setup.md": {
id: "git-symlinks-setup.md";
slug: "git-symlinks-setup";
body: string;
collection: "configurations";
data: InferEntrySchema<"configurations">
} & { render(): Render[".md"] };
};
"posts": {
"README.md": {
id: "README.md";
slug: "readme";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"Test.md": {
id: "Test.md";
slug: "test";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"cloudflare-tunnel-setup.md": {
id: "cloudflare-tunnel-setup.md";
slug: "cloudflare-tunnel-setup";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"filebrowser-setup.md": {
id: "filebrowser-setup.md";
slug: "filebrowser-setup";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"gitea-self-hosted-git.md": {
id: "gitea-self-hosted-git.md";
slug: "gitea-self-hosted-git";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"gitops-with-flux-cd.md": {
id: "gitops-with-flux-cd.md";
slug: "gitops-with-flux-cd";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"k3s-cluster.md": {
id: "k3s-cluster.md";
slug: "k3s-cluster";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"k3s-installation-guide.md": {
id: "k3s-installation-guide.md";
slug: "k3s-installation-guide";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"prometheus-monitoring.md": {
id: "prometheus-monitoring.md";
slug: "prometheus-monitoring";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"proxmox-setup-guide.md": {
id: "proxmox-setup-guide.md";
slug: "proxmox-setup-guide";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"quartz-digital-garden.md": {
id: "quartz-digital-garden.md";
slug: "quartz-digital-garden";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"rancher-kubernetes-management.md": {
id: "rancher-kubernetes-management.md";
slug: "rancher-kubernetes-management";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"starting-my-digital-garden.md": {
id: "starting-my-digital-garden.md";
slug: "starting-my-digital-garden";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"test-post.md": {
id: "test-post.md";
slug: "test-post";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
"vscode-server-remote-development.md": {
id: "vscode-server-remote-development.md";
slug: "vscode-server-remote-development";
body: string;
collection: "posts";
data: InferEntrySchema<"posts">
} & { render(): Render[".md"] };
};
"projects": {
"argobox.md": {
id: "argobox.md";
slug: "argobox";
body: string;
collection: "projects";
data: InferEntrySchema<"projects">
} & { render(): Render[".md"] };
"placeholder.md": {
id: "placeholder.md";
slug: "placeholder";
body: string;
collection: "projects";
data: InferEntrySchema<"projects">
} & { render(): Render[".md"] };
};
};
type DataEntryMap = {
};
type AnyEntryMap = ContentEntryMap & DataEntryMap;
type ContentConfig = typeof import("../src/content/config");
}

0
.env.example Normal file
View File

25
.gitattributes vendored
View File

@ -1,6 +1,8 @@
# Handle symbolic links as real content # Handle symbolic links as real content
public/blog/* !symlink
src/content/* !symlink
# Treat directories as regular even if symlinked # Treat these directories as regular directories even if they're symlinks
public/blog/configs/ -symlink public/blog/configs/ -symlink
public/blog/images/ -symlink public/blog/images/ -symlink
public/blog/infrastructure/ -symlink public/blog/infrastructure/ -symlink
@ -10,5 +12,24 @@ src/content/projects/ -symlink
src/content/configurations/ -symlink src/content/configurations/ -symlink
src/content/external-posts/ -symlink src/content/external-posts/ -symlink
# Text files # Set text files to automatically normalize line endings
* text=auto * text=auto
# Markdown files
*.md text
*.mdx text
# Source code
*.ts text
*.js text
*.json text
*.astro text
*.css text
*.html text
# Images should be treated as binary
*.png binary
*.jpg binary
*.gif binary
*.ico binary
*.svg text

29
.gitignore vendored
View File

@ -1,6 +1,27 @@
node_modules/ # build output
.env
*.log
dist/ dist/
.output/ .output/
.cache/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# Local development
.vscode/
*.local
# Obsidian files
.obsidian/
node_modules

43
SETUP.md Normal file
View File

@ -0,0 +1,43 @@
# Automated Git Hooks Setup
## Quick Start
```bash
# Clone and initialize the blog repository
git clone https://git.argobox.com/KeyArgo/laforceit-blog.git
cd laforceit-blog
./scripts/init-blog-repo.sh
```
## What This Does
The initialization script (`init-blog-repo.sh`) automatically:
1. Configures Git to handle symbolic links properly
2. Creates all necessary directories
3. Sets up symbolic links to your Obsidian content
4. Installs Git hooks that handle content conversion
5. Makes all scripts executable
## How Content Syncing Works
1. **Writing Content**
- Edit content normally in Obsidian
- Changes appear instantly in the blog repository through symbolic links
2. **Committing Changes**
- The pre-commit hook automatically converts symbolic links to real content
- Git commits the actual content
- The post-commit hook restores symbolic links automatically
3. **No Manual Steps Required**
- All conversions happen automatically
- No need to remember any special commands
- Works the same way for all contributors
## Troubleshooting
If commits seem to hang:
1. Check that `core.symlinks` is enabled: `git config core.symlinks`
2. Verify symbolic links are correct: `ls -la src/content/ public/blog/`
3. Run `./scripts/init-blog-repo.sh` to reset the setup
The initialization script can be run multiple times safely - it will fix any broken symbolic links or missing configuration.

26
astro.config.mjs Normal file
View File

@ -0,0 +1,26 @@
// @ts-check
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import tailwind from '@astrojs/tailwind';
// import cloudflare from '@astrojs/cloudflare'; // Commented out for local development
// https://astro.build/config
export default defineConfig({
site: 'https://laforceit.blog',
output: 'static',
// adapter: cloudflare(), // Commented out for local development
integrations: [
mdx(),
sitemap(),
tailwind(),
],
markdown: {
shikiConfig: {
theme: 'dracula',
wrap: true
},
remarkPlugins: [],
rehypePlugins: []
}
});

36
astro_files.txt Normal file
View File

@ -0,0 +1,36 @@
FullName
--------
C:\Projects\--Repo\laforceit-blog\node_modules\@astrojs\prism\Prism.astro
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\ClientRouter.astro
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Code.astro
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Debug.astro
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Font.astro
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Image.astro
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\Picture.astro
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\ResponsiveImage.astro
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\ResponsivePicture.astro
C:\Projects\--Repo\laforceit-blog\src\components\DigitalGardenGraph.astro
C:\Projects\--Repo\laforceit-blog\src\components\Footer.astro
C:\Projects\--Repo\laforceit-blog\src\components\Header.astro
C:\Projects\--Repo\laforceit-blog\src\components\Newsletter.astro
C:\Projects\--Repo\laforceit-blog\src\components\PostCard.astro
C:\Projects\--Repo\laforceit-blog\src\layouts\BaseLayout.astro
C:\Projects\--Repo\laforceit-blog\src\layouts\BlogPost.astro
C:\Projects\--Repo\laforceit-blog\src\layouts\BlogPostLayout.astro
C:\Projects\--Repo\laforceit-blog\src\pages\configurations.astro
C:\Projects\--Repo\laforceit-blog\src\pages\index.astro
C:\Projects\--Repo\laforceit-blog\src\pages\search.astro
C:\Projects\--Repo\laforceit-blog\src\pages\stack.astro
C:\Projects\--Repo\laforceit-blog\src\pages\subscription-confirmed.astro
C:\Projects\--Repo\laforceit-blog\src\pages\unsubscribe.astro
C:\Projects\--Repo\laforceit-blog\src\pages\blog\[slug].astro
C:\Projects\--Repo\laforceit-blog\src\pages\blog\index.astro
C:\Projects\--Repo\laforceit-blog\src\pages\categories\[category].astro
C:\Projects\--Repo\laforceit-blog\src\pages\categories\index.astro
C:\Projects\--Repo\laforceit-blog\src\pages\configurations\[slug].astro
C:\Projects\--Repo\laforceit-blog\src\pages\posts\[slug].astro
C:\Projects\--Repo\laforceit-blog\src\pages\projects\[slug].astro
C:\Projects\--Repo\laforceit-blog\src\pages\tag\[tag].astro
C:\Projects\--Repo\laforceit-blog\src\pages\tags\index.astro

View File

@ -0,0 +1,112 @@
---
import '../styles/global.css';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
interface Props {
title: string;
description?: string;
}
const { title, description = "LaForce IT - Home Lab & DevOps Insights" } = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<meta name="description" content={description}>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Open Graph / Social Media Meta Tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:image" content="/blog/images/placeholders/default.jpg" />
<!-- Twitter Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta property="twitter:domain" content="laforceit.blog" />
<meta property="twitter:url" content={Astro.url} />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content="/blog/images/placeholders/default.jpg" />
</head>
<body>
<!-- Neural network nodes - Added via JavaScript -->
<div id="neural-network"></div>
<!-- Floating shapes for background effect -->
<div class="floating-shapes">
<div class="floating-shape shape-1"></div>
<div class="floating-shape shape-2"></div>
<div class="floating-shape shape-3"></div>
</div>
<Header />
<slot />
<Footer />
<script>
// Create neural network nodes
document.addEventListener('DOMContentLoaded', () => {
const neuralNetwork = document.getElementById('neural-network');
if (!neuralNetwork) return;
const nodeCount = Math.min(window.innerWidth / 20, 70); // Responsive node count
for (let i = 0; i < nodeCount; i++) {
const node = document.createElement('div');
node.classList.add('neural-node');
// Random position
node.style.left = `${Math.random() * 100}%`;
node.style.top = `${Math.random() * 100}%`;
// Random animation delay
node.style.animationDelay = `${Math.random() * 4}s`;
neuralNetwork.appendChild(node);
}
});
// Terminal typing effect
document.addEventListener('DOMContentLoaded', () => {
const terminalTyping = document.querySelector('.terminal-typing');
if (!terminalTyping) return;
const typingCommands = [
'cloudflared tunnel status',
'kubectl get pods -A',
'helm list -n monitoring',
'flux reconcile kustomization --all'
];
let currentCommandIndex = 0;
function typeCommand(command: string, element: Element, index = 0) {
if (index < command.length) {
element.textContent = command.substring(0, index + 1);
setTimeout(() => typeCommand(command, element, index + 1), 100);
} else {
// Move to next command after delay
setTimeout(() => {
currentCommandIndex = (currentCommandIndex + 1) % typingCommands.length;
typeCommand(typingCommands[currentCommandIndex], element, 0);
}, 3000);
}
}
typeCommand(typingCommands[currentCommandIndex], terminalTyping);
});
</script>
</body>
</html>

View File

@ -0,0 +1,108 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
// Get all blog entries
const allPosts = await getCollection('blog');
// Sort by publication date
const sortedPosts = allPosts.sort((a, b) => {
const dateA = a.data.pubDate ? new Date(a.data.pubDate) : new Date(0);
const dateB = b.data.pubDate ? new Date(b.data.pubDate) : new Date(0);
return dateB.getTime() - dateA.getTime();
});
---
<BaseLayout title="Blog | LaForce IT - Home Lab & DevOps Insights" description="Explore articles about Kubernetes, Infrastructure, DevOps, and Home Lab setups">
<main class="container">
<section class="blog-header">
<h1 class="blog-title">Blog</h1>
<p class="blog-description">
Technical insights, infrastructure guides, and DevOps best practices from my home lab to production environments.
</p>
</section>
<div class="blog-grid">
{sortedPosts.map((post) => (
<article class="post-card">
{post.data.heroImage ? (
<img
width={720}
height={360}
src={post.data.heroImage}
alt=""
class="post-image"
/>
) : (
<img
width={720}
height={360}
src="/blog/images/placeholders/default.jpg"
alt=""
class="post-image"
/>
)}
<div class="post-content">
<div class="post-meta">
<time datetime={post.data.pubDate ? new Date(post.data.pubDate).toISOString() : ''}>
{post.data.pubDate ? new Date(post.data.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : 'No date'}
</time>
{post.data.category && (
<span class="post-category">
{post.data.category}
</span>
)}
</div>
<h3 class="post-title">
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>
{post.data.draft && <span class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 text-xs rounded">Draft</span>}
</h3>
<p class="post-excerpt">{post.data.description}</p>
<div class="post-footer">
<span class="post-read-time">{post.data.readTime || '5 min read'}</span>
<a href={`/blog/${post.slug}/`} class="read-more">Read More</a>
</div>
</div>
</article>
))}
</div>
</main>
</BaseLayout>
<style>
.blog-header {
margin: 3rem 0;
text-align: center;
}
.blog-title {
font-size: clamp(2rem, 5vw, 3.5rem);
margin-bottom: 1rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-tertiary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
display: inline-block;
}
.blog-description {
color: var(--text-secondary);
font-size: clamp(1rem, 2vw, 1.2rem);
max-width: 700px;
margin: 0 auto;
}
.blog-grid {
margin: 2rem 0 4rem;
}
@media (max-width: 768px) {
.blog-header {
margin: 2rem 0;
}
}
</style>

832
backup-20250422/global.css Normal file
View File

@ -0,0 +1,832 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--bg-primary: #050a18;
--bg-secondary: #0d1529;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--accent-primary: #06b6d4;
--accent-secondary: #3b82f6;
--accent-tertiary: #8b5cf6;
--glow-primary: rgba(6, 182, 212, 0.3);
--glow-secondary: rgba(59, 130, 246, 0.3);
--card-bg: rgba(15, 23, 42, 0.8);
--card-border: rgba(56, 189, 248, 0.2);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Space Grotesk', sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
overflow-x: hidden;
background-image:
radial-gradient(circle at 20% 35%, rgba(6, 182, 212, 0.05) 0%, transparent 50%),
radial-gradient(circle at 75% 15%, rgba(59, 130, 246, 0.05) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(139, 92, 246, 0.05) 0%, transparent 40%);
position: relative;
}
/* Grid overlay effect */
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(226, 232, 240, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(226, 232, 240, 0.03) 1px, transparent 1px);
background-size: 30px 30px;
pointer-events: none;
z-index: -1;
}
/* Neural network nodes */
.neural-node {
position: fixed;
width: 2px;
height: 2px;
background: rgba(226, 232, 240, 0.2);
border-radius: 50%;
animation: pulse 4s infinite alternate ease-in-out;
z-index: -1;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.3;
}
100% {
transform: scale(1.5);
opacity: 0.6;
}
}
/* Terminal cursor animation */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Header styles */
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem clamp(1rem, 5%, 3rem);
position: relative;
background: linear-gradient(180deg, var(--bg-secondary), transparent);
border-bottom: 1px solid rgba(56, 189, 248, 0.1);
}
.logo {
display: flex;
align-items: center;
gap: 1rem;
font-weight: 600;
font-size: 1.5rem;
text-decoration: none;
color: var(--text-primary);
}
.logo span {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.logo-symbol {
width: 2.5rem;
height: 2.5rem;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
font-family: 'JetBrains Mono', monospace;
font-weight: bold;
font-size: 1.25rem;
color: var(--bg-primary);
box-shadow: 0 0 15px var(--glow-primary);
}
nav {
display: flex;
gap: 2rem;
}
nav a {
color: var(--text-secondary);
text-decoration: none;
font-weight: 500;
transition: all 0.3s ease;
position: relative;
}
nav a:hover {
color: var(--text-primary);
}
nav a::after {
content: '';
position: absolute;
width: 0;
height: 2px;
bottom: -5px;
left: 0;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
transition: width 0.3s ease;
}
nav a:hover::after {
width: 100%;
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
}
/* Floating shapes */
.floating-shapes {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.floating-shape {
position: absolute;
border-radius: 50%;
opacity: 0.05;
filter: blur(30px);
}
.shape-1 {
width: 300px;
height: 300px;
background: var(--accent-primary);
top: 20%;
right: 0;
}
.shape-2 {
width: 200px;
height: 200px;
background: var(--accent-secondary);
bottom: 10%;
left: 10%;
}
.shape-3 {
width: 150px;
height: 150px;
background: var(--accent-tertiary);
top: 70%;
right: 20%;
}
/* Blog post cards */
.post-card {
background: var(--card-bg);
border-radius: 10px;
border: 1px solid var(--card-border);
overflow: hidden;
transition: all 0.3s ease;
position: relative;
z-index: 1;
}
.post-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(6, 182, 212, 0.1);
border-color: rgba(56, 189, 248, 0.4);
}
.post-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(6, 182, 212, 0.05), rgba(139, 92, 246, 0.05));
z-index: -1;
opacity: 0;
transition: opacity 0.3s ease;
}
.post-card:hover::before {
opacity: 1;
}
.post-image {
width: 100%;
height: 200px;
object-fit: cover;
border-bottom: 1px solid var(--card-border);
}
.post-content {
padding: 1.5rem;
}
.post-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--text-secondary);
}
.post-category {
background: rgba(6, 182, 212, 0.1);
color: var(--accent-primary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
}
.post-title {
font-size: 1.25rem;
margin-bottom: 0.75rem;
line-height: 1.3;
}
.post-title a {
color: var(--text-primary);
text-decoration: none;
transition: color 0.3s ease;
}
.post-title a:hover {
color: var(--accent-primary);
}
.post-excerpt {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1.5rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-footer {
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-secondary);
font-size: 0.85rem;
}
.read-more {
color: var(--accent-primary);
text-decoration: none;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.25rem;
transition: color 0.3s ease;
}
.read-more:hover {
color: var(--accent-secondary);
}
.read-more::after {
content: '→';
}
/* Section styles */
.section-title {
font-size: clamp(1.5rem, 3vw, 2.5rem);
margin-bottom: 1rem;
position: relative;
display: inline-block;
color: var(--text-primary);
}
.section-title::after {
content: '';
position: absolute;
height: 4px;
width: 60px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
bottom: -10px;
left: 0;
border-radius: 2px;
}
/* Footer styles */
footer {
background: var(--bg-secondary);
padding: 3rem clamp(1rem, 5%, 3rem);
position: relative;
border-top: 1px solid rgba(56, 189, 248, 0.1);
margin-top: 5rem;
}
.footer-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.footer-col h4 {
font-size: 1.1rem;
margin-bottom: 1.5rem;
color: var(--text-primary);
}
.footer-links {
list-style: none;
}
.footer-links li {
margin-bottom: 0.75rem;
}
.footer-links a {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.3s ease;
}
.footer-links a:hover {
color: var(--accent-primary);
}
.social-links {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.social-link {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(226, 232, 240, 0.05);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.3s ease;
}
.social-link:hover {
background: var(--accent-primary);
color: var(--bg-primary);
transform: translateY(-3px);
}
.footer-bottom {
text-align: center;
padding-top: 2rem;
border-top: 1px solid rgba(226, 232, 240, 0.05);
color: var(--text-secondary);
font-size: 0.9rem;
}
.footer-bottom a {
color: var(--accent-primary);
text-decoration: none;
}
/* Hero section for homepage */
.hero {
min-height: 80vh;
display: flex;
align-items: center;
padding: 3rem clamp(1rem, 5%, 3rem);
position: relative;
overflow: hidden;
}
.hero-content {
max-width: 650px;
z-index: 1;
}
.hero-subtitle {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-primary);
font-size: 0.9rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.hero-subtitle::before {
content: '>';
font-weight: bold;
}
.hero-title {
font-size: clamp(2.5rem, 5vw, 4rem);
line-height: 1.1;
margin-bottom: 1.5rem;
}
.hero-title span {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-tertiary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero-description {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 2rem;
max-width: 85%;
}
.cta-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
color: var(--bg-primary);
font-weight: 600;
padding: 0.75rem 1.5rem;
border-radius: 8px;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 0 20px var(--glow-primary);
}
.cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 0 30px var(--glow-primary);
}
/* Terminal box */
.terminal-box {
width: 100%;
background: var(--bg-secondary);
border-radius: 10px;
border: 1px solid var(--card-border);
box-shadow: 0 0 30px rgba(6, 182, 212, 0.1);
padding: 1.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
display: flex;
flex-direction: column;
z-index: 1;
overflow: hidden;
margin: 2rem 0;
}
.terminal-header {
display: flex;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(226, 232, 240, 0.1);
}
.terminal-dots {
display: flex;
gap: 0.5rem;
}
.terminal-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.terminal-dot-red {
background: #ef4444;
}
.terminal-dot-yellow {
background: #eab308;
}
.terminal-dot-green {
background: #22c55e;
}
.terminal-title {
margin-left: auto;
margin-right: auto;
color: var(--text-secondary);
font-size: 0.8rem;
}
.terminal-content {
flex: 1;
color: var(--text-secondary);
}
.terminal-line {
margin-bottom: 0.75rem;
display: flex;
}
.terminal-prompt {
color: var(--accent-primary);
margin-right: 0.5rem;
}
.terminal-command {
color: var(--text-primary);
}
.terminal-output {
color: var(--text-secondary);
padding-left: 1.5rem;
margin-bottom: 0.75rem;
}
.terminal-typing {
position: relative;
}
.terminal-typing::after {
content: '|';
position: absolute;
right: -10px;
animation: blink 1s infinite;
}
/* Container and content layout */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 1.5rem;
}
main {
padding: 2rem 0;
}
/* Blog content styling */
.blog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
/* Digital Garden */
.digital-garden-intro {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 2rem;
max-width: 800px;
}
/* Responsive adjustments */
@media (max-width: 1024px) {
.hero {
flex-direction: column;
align-items: flex-start;
}
.hero-content {
max-width: 100%;
}
}
@media (max-width: 768px) {
header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
nav {
width: 100%;
justify-content: space-between;
}
.hero-description {
max-width: 100%;
}
.blog-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
.hero {
padding: 2rem 1rem;
flex-direction: column;
gap: 2rem;
}
.hero-content {
max-width: 100%;
}
.hero-title {
font-size: clamp(1.5rem, 6vw, 2.5rem);
}
.terminal-box {
width: 100%;
min-height: 300px;
max-width: 100%;
}
.footer-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
.post-card {
min-height: auto;
}
.featured-grid {
grid-template-columns: 1fr;
}
.post-metadata {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.post-info {
flex-wrap: wrap;
}
.post-tags {
margin-top: 1rem;
}
/* Add mobile menu functionality */
.mobile-menu-btn {
display: block;
}
nav.desktop-nav {
display: none;
}
nav.mobile-nav-active {
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: var(--bg-primary);
z-index: 1000;
padding: 2rem;
align-items: flex-start;
}
nav.mobile-nav-active a {
font-size: 1.5rem;
margin-bottom: 1.5rem;
}
.mobile-menu-close {
align-self: flex-end;
background: none;
border: none;
color: var(--text-primary);
font-size: 1.5rem;
cursor: pointer;
margin-bottom: 2rem;
}
}
@media (max-width: 640px) {
.mobile-menu-btn {
display: block;
position: absolute;
right: 1.5rem;
top: 1.5rem;
}
nav {
display: none;
}
.blog-grid {
grid-template-columns: 1fr;
}
}
/* Additional mobile optimizations for very small screens */
@media (max-width: 480px) {
:root {
--container-padding: 0.75rem;
}
.post-title {
font-size: 1.5rem;
}
.post-card {
border-radius: 0.5rem;
}
.post-image {
height: 160px;
}
.section-title {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.hero-subtitle {
font-size: 0.8rem;
}
.cta-button {
width: 100%;
text-align: center;
}
.post-content {
font-size: 1rem;
line-height: 1.6;
}
/* Adjust footer layout */
.footer-col {
margin-bottom: 1.5rem;
}
.footer-bottom {
flex-direction: column;
gap: 1rem;
text-align: center;
}
}
/* Touch device optimizations */
@media (hover: none) {
.post-card:hover {
transform: none;
}
.cta-button:hover {
transform: none;
}
nav a:hover::after {
width: 100%;
}
.social-link:hover {
transform: none;
}
.post-tag:hover {
transform: none;
}
/* Increase tap target sizes */
nav a {
padding: 0.5rem 0;
}
.footer-links li {
margin-bottom: 0.75rem;
}
.footer-links a {
padding: 0.5rem 0;
display: inline-block;
}
.post-footer {
padding: 1rem;
}
}

566
backup-20250422/index.astro Normal file
View File

@ -0,0 +1,566 @@
---
import { getCollection } from 'astro:content';
import type { CollectionEntry } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import DigitalGardenGraph from '../components/DigitalGardenGraph.astro';
type Post = CollectionEntry<'posts'>;
type Config = CollectionEntry<'configurations'>;
type Project = CollectionEntry<'projects'>;
// Get all blog posts (excluding configurations and specific guides)
const posts = (await getCollection('blog'))
.filter(item =>
!item.slug.startsWith('configurations/') &&
!item.slug.startsWith('projects/') &&
!item.data.category?.toLowerCase().includes('configuration') &&
!item.slug.includes('setup-guide') &&
!item.slug.includes('config')
)
.sort((a, b) => new Date(b.data.pubDate || 0).valueOf() - new Date(a.data.pubDate || 0).valueOf());
// Get configuration posts
const configurations = (await getCollection('blog'))
.filter(item =>
item.slug.startsWith('configurations/') ||
item.data.category?.toLowerCase().includes('configuration') ||
item.slug.includes('setup-guide') ||
item.slug.includes('config') ||
item.slug.includes('monitoring') ||
item.slug.includes('server') ||
item.slug.includes('tunnel')
)
.sort((a, b) => new Date(b.data.pubDate || 0).valueOf() - new Date(a.data.pubDate || 0).valueOf());
// Get project posts
const projects = (await getCollection('blog'))
.filter(item =>
item.slug.startsWith('projects/') ||
item.data.category?.toLowerCase().includes('project')
)
.sort((a, b) => new Date(b.data.pubDate || 0).valueOf() - new Date(a.data.pubDate || 0).valueOf());
---
<BaseLayout title="LaForce IT - Home Lab & DevOps Insights">
<!-- Hero section -->
<section class="hero">
<div class="hero-content">
<div class="hero-subtitle">Home Lab & DevOps</div>
<h1 class="hero-title">Exploring <span>advanced infrastructure</span> and automation</h1>
<p class="hero-description">
Join me on a journey through enterprise-grade home lab setups, Kubernetes deployments, and DevOps best practices for the modern tech enthusiast.
</p>
<div class="social-links-hero">
<a href="https://github.com/keyargo" target="_blank" rel="noopener noreferrer" class="social-link-hero github">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg>
</a>
<a href="https://linkedin.com/in/danlaforce" target="_blank" rel="noopener noreferrer" class="social-link-hero linkedin">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"></circle></svg>
</a>
</div>
<a href="#posts" class="cta-button">
Explore Latest Posts
</a>
</div>
<div class="terminal-box">
<div class="terminal-header">
<div class="terminal-dots">
<div class="terminal-dot terminal-dot-red"></div>
<div class="terminal-dot terminal-dot-yellow"></div>
<div class="terminal-dot terminal-dot-green"></div>
</div>
<div class="terminal-title">argobox:~/homelab</div>
</div>
<div class="terminal-content">
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-command">kubectl get nodes</span>
</div>
<div class="terminal-output">
NAME STATUS ROLES AGE VERSION<br>
argobox Ready &lt;none&gt; 47d v1.28.3+k3s1<br>
argobox-lite Ready control-plane,master 47d v1.28.3+k3s1
</div>
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-command">helm list -A</span>
</div>
<div class="terminal-output">
NAME NAMESPACE REVISION STATUS CHART<br>
cloudnative-pg postgres 1 deployed cloudnative-pg-0.18.0<br>
prometheus monitoring 2 deployed kube-prometheus-stack-51.2.0
</div>
<div class="terminal-line">
<span class="terminal-prompt">$</span>
<span class="terminal-command terminal-typing">cloudflared tunnel status</span>
</div>
</div>
</div>
</section>
<!-- Digital Garden Visualization -->
<section class="container">
<h2 class="section-title">My Digital Garden</h2>
<p class="digital-garden-intro">
This blog functions as my personal digital garden - a collection of interconnected ideas, guides, and projects.
Browse through the visualization below to see how different concepts relate to each other.
</p>
<DigitalGardenGraph />
</section>
<!-- Main content sections -->
<main class="container">
<section id="posts" class="mb-16">
<h2 class="section-title">Latest Posts</h2>
<div class="blog-grid">
{posts.map((post) => (
<article class="post-card">
{post.data.heroImage ? (
<img
width={720}
height={360}
src={post.data.heroImage}
alt=""
class="post-image"
/>
) : (
<img
width={720}
height={360}
src="/blog/images/placeholders/default.jpg"
alt=""
class="post-image"
/>
)}
<div class="post-content">
<div class="post-meta">
<time datetime={post.data.pubDate ? new Date(post.data.pubDate).toISOString() : ''}>
{post.data.pubDate ? new Date(post.data.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : 'No date'}
</time>
{post.data.category && (
<span class="post-category">
{post.data.category}
</span>
)}
</div>
<h3 class="post-title">
<a href={`/blog/${post.slug}/`}>{post.data.title}</a>
{post.data.draft && <span class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 text-xs rounded">Draft</span>}
</h3>
<p class="post-excerpt">{post.data.description}</p>
<div class="post-footer">
<span class="post-read-time">{post.data.readTime || '5 min read'}</span>
<a href={`/blog/${post.slug}/`} class="read-more">Read More</a>
</div>
</div>
</article>
))}
</div>
</section>
<section id="configurations" class="mb-16">
<h2 class="section-title">Configurations</h2>
<div class="blog-grid">
{configurations.map((config) => (
<article class="post-card">
{config.data.heroImage ? (
<img
width={720}
height={360}
src={config.data.heroImage}
alt=""
class="post-image"
/>
) : (
<img
width={720}
height={360}
src="/blog/images/placeholders/default.jpg"
alt=""
class="post-image"
/>
)}
<div class="post-content">
<div class="post-meta">
<time datetime={config.data.pubDate ? new Date(config.data.pubDate).toISOString() : ''}>
{config.data.pubDate ? new Date(config.data.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : 'No date'}
</time>
{config.data.category && (
<span class="post-category">
{config.data.category}
</span>
)}
</div>
<h3 class="post-title">
<a href={`/blog/${config.slug}/`}>{config.data.title}</a>
{config.data.draft && <span class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 text-xs rounded">Draft</span>}
</h3>
<p class="post-excerpt">{config.data.description}</p>
<div class="post-footer">
<span class="post-read-time">{config.data.readTime || '5 min read'}</span>
<a href={`/blog/${config.slug}/`} class="read-more">Read More</a>
</div>
</div>
</article>
))}
</div>
</section>
<section id="projects" class="mb-16">
<h2 class="section-title">Projects</h2>
<div class="blog-grid">
{projects.map((project) => (
<article class="post-card">
{project.data.heroImage ? (
<img
width={720}
height={360}
src={project.data.heroImage}
alt=""
class="post-image"
/>
) : (
<img
width={720}
height={360}
src="/blog/images/placeholders/default.jpg"
alt=""
class="post-image"
/>
)}
<div class="post-content">
<div class="post-meta">
<time datetime={project.data.pubDate ? new Date(project.data.pubDate).toISOString() : ''}>
{project.data.pubDate ? new Date(project.data.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : 'No date'}
</time>
{project.data.category && (
<span class="post-category">
{project.data.category}
</span>
)}
</div>
<h3 class="post-title">
<a href={`/blog/${project.slug}/`}>{project.data.title}</a>
{project.data.draft && <span class="ml-2 px-2 py-1 bg-gray-200 text-gray-700 text-xs rounded">Draft</span>}
</h3>
{project.data.technologies && (
<div class="mb-2 flex flex-wrap gap-2">
{project.data.technologies.map((tech) => (
<span class="post-category">
{tech}
</span>
))}
</div>
)}
<p class="post-excerpt">{project.data.description}</p>
<div class="post-footer">
<div class="flex gap-4">
{project.data.github && (
<a href={project.data.github} target="_blank" rel="noopener noreferrer" class="read-more">
GitHub
</a>
)}
{project.data.live && (
<a href={project.data.live} target="_blank" rel="noopener noreferrer" class="read-more">
Live Demo
</a>
)}
</div>
<a href={`/blog/${project.slug}/`} class="read-more">View Project</a>
</div>
</div>
</article>
))}
</div>
</section>
<!-- Featured section -->
<section class="featured-section">
<div class="featured-grid">
<div class="featured-content">
<div class="featured-subtitle">Featured Project</div>
<h2 class="featured-title">ArgoBox <span>Home Lab Architecture</span></h2>
<p class="featured-description">
A complete enterprise-grade home infrastructure built on Kubernetes, featuring high availability, zero-trust networking, and fully automated deployments.
</p>
<ul class="featured-list">
<li class="featured-list-item">
<div class="featured-list-icon">✓</div>
<div>Multi-node K3s cluster with automatic failover</div>
</li>
<li class="featured-list-item">
<div class="featured-list-icon">✓</div>
<div>Gitea + Flux CD for GitOps-based continuous deployment</div>
</li>
<li class="featured-list-item">
<div class="featured-list-icon">✓</div>
<div>Cloudflare Tunnels for secure, zero-trust remote access</div>
</li>
<li class="featured-list-item">
<div class="featured-list-icon">✓</div>
<div>Synology NAS integration with Kubernetes volumes</div>
</li>
</ul>
<a href="#" class="cta-button">
View Project Details
</a>
</div>
</div>
</section>
<!-- About Me Section -->
<section class="about-section mb-16">
<h2 class="section-title">About Me</h2>
<div class="about-content">
<div class="about-text">
<p>
Hi, I'm Daniel LaForce, a passionate DevOps and infrastructure engineer with a focus on Kubernetes,
automation, and cloud technologies. When I'm not working on enterprise systems, I'm building and
refining my home lab environment to test and learn new technologies.
</p>
<p>
This site serves as both my technical blog and digital garden - a place to share what I've learned
and document my ongoing projects. Feel free to connect with me on GitHub or LinkedIn!
</p>
<div class="social-links">
<a href="https://github.com/keyargo" target="_blank" rel="noopener noreferrer" class="social-link">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path></svg>
<span>GitHub</span>
</a>
<a href="https://linkedin.com/in/danlaforce" target="_blank" rel="noopener noreferrer" class="social-link">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"></circle></svg>
<span>LinkedIn</span>
</a>
</div>
</div>
</div>
</section>
</main>
</BaseLayout>
<style>
.featured-section {
margin-top: 4rem;
background: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--card-border);
padding: 2rem;
position: relative;
overflow: hidden;
}
.featured-grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
.featured-subtitle {
font-family: 'JetBrains Mono', monospace;
color: var(--accent-primary);
font-size: 0.9rem;
letter-spacing: 2px;
text-transform: uppercase;
margin-bottom: 1rem;
}
.featured-title {
font-size: clamp(1.8rem, 4vw, 2.5rem);
line-height: 1.2;
margin-bottom: 1.5rem;
}
.featured-title span {
background: linear-gradient(90deg, var(--accent-primary), var(--accent-tertiary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.featured-description {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 1.5rem;
max-width: 600px;
}
.featured-list {
list-style: none;
margin-bottom: 2rem;
}
.featured-list-item {
display: flex;
margin-bottom: 0.75rem;
align-items: flex-start;
}
.featured-list-icon {
color: var(--accent-primary);
margin-right: 1rem;
font-weight: bold;
}
.mb-16 {
margin-bottom: 4rem;
}
.flex {
display: flex;
}
.flex-wrap {
flex-wrap: wrap;
}
.gap-2 {
gap: 0.5rem;
}
.gap-4 {
gap: 1rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.py-1 {
padding-top: 0.25rem;
padding-bottom: 0.25rem;
}
.bg-gray-200 {
background-color: rgba(226, 232, 240, 0.2);
}
.text-gray-700 {
color: #94a3b8;
}
.text-xs {
font-size: 0.75rem;
}
.rounded {
border-radius: 0.25rem;
}
.social-links-hero {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.social-link-hero {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--card-bg);
color: var(--text-primary);
transition: all 0.3s ease;
border: 1px solid var(--card-border);
}
.social-link-hero:hover {
transform: translateY(-3px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.social-link-hero.github:hover {
background-color: #24292e;
border-color: #24292e;
}
.social-link-hero.linkedin:hover {
background-color: #0077b5;
border-color: #0077b5;
}
.about-section {
background: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--card-border);
padding: 2rem;
position: relative;
overflow: hidden;
}
.about-content {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
.about-text {
color: var(--text-secondary);
font-size: 1.1rem;
line-height: 1.6;
}
.about-text p {
margin-bottom: 1.5rem;
}
.social-links {
display: flex;
gap: 1.5rem;
margin-top: 2rem;
}
.social-link {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-primary);
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background-color: rgba(226, 232, 240, 0.05);
transition: all 0.3s ease;
}
.social-link:hover {
background-color: rgba(226, 232, 240, 0.1);
transform: translateY(-2px);
}
@media (min-width: 768px) {
.featured-grid {
grid-template-columns: 1fr;
}
.about-content {
grid-template-columns: 1fr;
}
}
@media (min-width: 1024px) {
.about-content {
grid-template-columns: 1fr;
}
}
</style>

58
css_files.txt Normal file
View File

@ -0,0 +1,58 @@
FullName
--------
C:\Projects\--Repo\laforceit-blog\node_modules\@astrojs\tailwind\base.css
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\image.css
C:\Projects\--Repo\laforceit-blog\node_modules\astro\components\viewtransitions.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\autolinker\prism-autolinker.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\autolinker\prism-autolinker.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\command-line\prism-command-line.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\command-line\prism-command-line.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\diff-highlight\prism-diff-highlight.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\diff-highlight\prism-diff-highlight.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\inline-color\prism-inline-color.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\inline-color\prism-inline-color.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\line-highlight\prism-line-highlight.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\line-highlight\prism-line-highlight.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\line-numbers\prism-line-numbers.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\line-numbers\prism-line-numbers.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\match-braces\prism-match-braces.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\match-braces\prism-match-braces.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\previewers\prism-previewers.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\previewers\prism-previewers.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\show-invisibles\prism-show-invisibles.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\show-invisibles\prism-show-invisibles.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\toolbar\prism-toolbar.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\toolbar\prism-toolbar.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\treeview\prism-treeview.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\treeview\prism-treeview.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\unescaped-markup\prism-unescaped-markup.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\unescaped-markup\prism-unescaped-markup.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\wpd\prism-wpd.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\plugins\wpd\prism-wpd.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-coy.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-coy.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-dark.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-dark.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-funky.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-funky.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-okaidia.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-okaidia.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-solarizedlight.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-solarizedlight.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-tomorrow.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-tomorrow.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-twilight.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism-twilight.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism.css
C:\Projects\--Repo\laforceit-blog\node_modules\prismjs\themes\prism.min.css
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\base.css
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\components.css
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\screens.css
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\tailwind.css
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\utilities.css
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\variants.css
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\lib\css\preflight.css
C:\Projects\--Repo\laforceit-blog\node_modules\tailwindcss\src\css\preflight.css
C:\Projects\--Repo\laforceit-blog\src\styles\global.css

10342
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "laforceit-blog",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/cloudflare": "^7.0.0",
"@astrojs/mdx": "^2.0.0",
"@astrojs/rss": "^4.0.11",
"@astrojs/sitemap": "^3.3.0",
"@astrojs/tailwind": "^5.0.0",
"astro": "^3.6.5",
"tailwindcss": "^3.0.24"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16"
}
}

0
prisma/schema.prisma Normal file
View File

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

Before

Width:  |  Height:  |  Size: 2.7 MiB

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@ -1,52 +0,0 @@
---
title: "High-Availability Kubernetes Setup Guide"
description: "Build a resilient multi-node Kubernetes cluster for production workloads"
pubDate: "2024-03-20"
heroImage: "/blog/images/ha-kubernetes.jpg"
category: "Infrastructure"
tags: ["kubernetes", "high-availability", "infrastructure", "clustering"]
draft: true
---
<div class="draft-indicator">
<span class="draft-icon">⚠️</span>
<span class="draft-text">This article is a draft and needs to be written</span>
</div>
# High-Availability Kubernetes Setup Guide
This guide will walk you through setting up a highly available Kubernetes cluster. We'll cover:
- Architecture planning
- Control plane redundancy
- etcd cluster setup
- Load balancer configuration
- Node management
- Network configuration
- Storage considerations
- Backup and disaster recovery
- Monitoring and alerting
[Content coming soon...]
<style>
.draft-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.2);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.draft-icon {
font-size: 1.5rem;
}
.draft-text {
color: rgb(234, 179, 8);
font-weight: 500;
}
</style>

View File

@ -1,52 +0,0 @@
---
title: "NAS Integration with Kubernetes Guide"
description: "Learn how to integrate your NAS storage with Kubernetes for persistent data"
pubDate: "2024-03-20"
heroImage: "/blog/images/nas-integration.jpg"
category: "Infrastructure"
tags: ["kubernetes", "storage", "nas", "infrastructure"]
draft: true
---
<div class="draft-indicator">
<span class="draft-icon">⚠️</span>
<span class="draft-text">This article is a draft and needs to be written</span>
</div>
# NAS Integration with Kubernetes Guide
This guide will walk you through integrating your NAS storage with Kubernetes. We'll cover:
- NFS server setup
- Storage class configuration
- Persistent volume claims
- Dynamic provisioning
- Backup strategies
- Performance optimization
- Security considerations
- Monitoring and maintenance
- Common troubleshooting
[Content coming soon...]
<style>
.draft-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.2);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.draft-icon {
font-size: 1.5rem;
}
.draft-text {
color: rgb(234, 179, 8);
font-weight: 500;
}
</style>

View File

@ -1,53 +0,0 @@
---
title: "Home Lab Network Security Guide"
description: "Implement comprehensive network security measures for your home lab"
pubDate: "2024-03-20"
heroImage: "/blog/images/network-security.jpg"
category: "Infrastructure"
tags: ["security", "networking", "infrastructure", "firewall"]
draft: true
---
<div class="draft-indicator">
<span class="draft-icon">⚠️</span>
<span class="draft-text">This article is a draft and needs to be written</span>
</div>
# Home Lab Network Security Guide
This guide will walk you through implementing robust network security for your home lab. We'll cover:
- Network segmentation with VLANs
- Firewall configuration
- Intrusion Detection/Prevention
- Network monitoring
- Access control policies
- SSL/TLS implementation
- Security logging
- Vulnerability scanning
- Incident response
- Security best practices
[Content coming soon...]
<style>
.draft-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.2);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.draft-icon {
font-size: 1.5rem;
}
.draft-text {
color: rgb(234, 179, 8);
font-weight: 500;
}
</style>

View File

@ -1,52 +0,0 @@
---
title: "Secure VPN Access Setup Guide"
description: "Set up a secure VPN solution for remote access to your home lab"
pubDate: "2024-03-20"
heroImage: "/blog/images/secure-vpn.jpg"
category: "Infrastructure"
tags: ["security", "vpn", "networking", "infrastructure"]
draft: true
---
<div class="draft-indicator">
<span class="draft-icon">⚠️</span>
<span class="draft-text">This article is a draft and needs to be written</span>
</div>
# Secure VPN Access Setup Guide
This guide will walk you through setting up secure VPN access to your home lab. We'll cover:
- VPN server selection and setup
- Certificate management
- Network configuration
- Client setup
- Split tunneling
- Access control
- Monitoring and logging
- Security best practices
- Troubleshooting common issues
[Content coming soon...]
<style>
.draft-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.2);
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.draft-icon {
font-size: 1.5rem;
}
.draft-text {
color: rgb(234, 179, 8);
font-weight: 500;
}
</style>

View File

@ -1,27 +0,0 @@
---
title: "Blog Posts Collection"
description: "Documentation for blog posts"
pubDate: 2025-04-18
draft: true
---
# Blog Posts Collection
This directory contains blog posts for the LaForceIT digital garden.
## Content Guidelines
- All posts should include proper frontmatter
- Use Markdown for formatting content
- Images should be placed in the public/blog/images directory
## Frontmatter Requirements
Every post needs at minimum:
```
---
title: "Post Title"
pubDate: YYYY-MM-DD
---
```

View File

@ -1,14 +0,0 @@
---
title: Secure Remote Access with Cloudflare Tunnels
description: How to set up Cloudflare Tunnels for secure remote access to your home lab services
pubDate: Jul 22 2023
heroImage: /blog/images/posts/prometheusk8.png
category: networking
tags:
- cloudflare
- networking
- security
- homelab
- tunnels
readTime: "7 min read"
---

View File

@ -1,180 +0,0 @@
---
title: Secure Remote Access with Cloudflare Tunnels
description: How to set up Cloudflare Tunnels for secure remote access to your home lab services
pubDate: 2025-04-19
heroImage: /blog/images/posts/prometheusk8.png
category: networking
tags:
- cloudflare
- networking
- security
- homelab
- tunnels
readTime: 7 min read
---
# Secure Remote Access with Cloudflare Tunnels
Cloudflare Tunnels provide a secure way to expose your locally hosted applications and services to the internet without opening ports on your firewall or requiring a static IP address. This guide will show you how to set up Cloudflare Tunnels to securely access your home lab services from anywhere.
## Why Use Cloudflare Tunnels?
- **Security**: No need to open ports on your firewall
- **Simplicity**: Works behind CGNAT, dynamic IPs, and complex network setups
- **Performance**: Traffic routed through Cloudflare's global network
- **Zero Trust**: Integrate with Cloudflare Access for authentication
## Prerequisites
- A Cloudflare account
- A domain managed by Cloudflare
- Docker installed (for containerized deployment)
- Services you want to expose (e.g., web apps, SSH, etc.)
## Setting Up Cloudflare Tunnels
### 1. Install cloudflared
You can install cloudflared using Docker:
```bash
docker pull cloudflare/cloudflared:latest
```
Or directly on your system:
```bash
# For Debian/Ubuntu
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
# For other systems, visit: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation
```
### 2. Authenticate cloudflared
Run the following command to authenticate:
```bash
cloudflared tunnel login
```
This will open a browser window where you'll need to log in to your Cloudflare account and select the domain you want to use with the tunnel.
### 3. Create a Tunnel
Create a new tunnel with a meaningful name:
```bash
cloudflared tunnel create homelab
```
This will generate a tunnel ID and credentials file at `~/.cloudflared/`.
### 4. Configure your Tunnel
Create a config file at `~/.cloudflared/config.yml`:
```yaml
tunnel: <TUNNEL_ID>
credentials-file: /root/.cloudflared/<TUNNEL_ID>.json
ingress:
# Dashboard application
- hostname: dashboard.yourdomain.com
service: http://localhost:8080
# Grafana service
- hostname: grafana.yourdomain.com
service: http://localhost:3000
# SSH service
- hostname: ssh.yourdomain.com
service: ssh://localhost:22
# Catch-all rule, which responds with 404
- service: http_status:404
```
### 5. Route Traffic to Your Tunnel
Configure DNS records to route traffic to your tunnel:
```bash
cloudflared tunnel route dns homelab dashboard.yourdomain.com
cloudflared tunnel route dns homelab grafana.yourdomain.com
cloudflared tunnel route dns homelab ssh.yourdomain.com
```
### 6. Start the Tunnel
Run the tunnel:
```bash
cloudflared tunnel run homelab
```
For production deployments, you'll want to set up cloudflared as a service:
```bash
# For systemd-based systems
sudo cloudflared service install
sudo systemctl start cloudflared
```
## Docker Compose Example
For a containerized deployment, create a `docker-compose.yml` file:
```yaml
version: '3.8'
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
command: tunnel run
environment:
- TUNNEL_TOKEN=your_tunnel_token
volumes:
- ~/.cloudflared:/etc/cloudflared
```
## Security Considerations
- Store your credentials file safely; it provides full access to your tunnel
- Consider using Cloudflare Access for additional authentication
- Regularly rotate credentials and update cloudflared
## Advanced Configuration
### Zero Trust Access
You can integrate Cloudflare Tunnels with Cloudflare Access to require authentication:
```yaml
ingress:
- hostname: dashboard.yourdomain.com
service: http://localhost:8080
originRequest:
noTLSVerify: true
```
Then, create an Access application in the Cloudflare Zero Trust dashboard to protect this hostname.
### Health Checks
Configure health checks to ensure your services are running:
```yaml
ingress:
- hostname: dashboard.yourdomain.com
service: http://localhost:8080
originRequest:
healthCheckEnabled: true
healthCheckPath: /health
```
## Conclusion
Cloudflare Tunnels provide a secure, reliable way to access your home lab services remotely without exposing your home network to the internet. With the setup described in this guide, you can securely access your services from anywhere in the world.

View File

@ -1,206 +0,0 @@
---
title: Setting Up FileBrowser for Self-Hosted File Management
description: A step-by-step guide to deploying and configuring FileBrowser for secure, user-friendly file management in your home lab environment.
pubDate: 2025-04-19
updatedDate: 2025-04-18
category: Services
tags:
- filebrowser
- self-hosted
- kubernetes
- docker
- file-management
heroImage: /blog/images/posts/prometheusk8.png
---
I've said it before, and I'll say it again - the journey to a well-organized digital life begins with proper file management. If you're like me, you've got files scattered across multiple devices, cloud services, and servers. What if I told you there's a lightweight, sleek solution that puts you back in control without relying on third-party services?
Enter [FileBrowser](https://filebrowser.org/), a simple yet powerful self-hosted file management interface that I've been using in my home lab for the past few months. Let me show you how to set it up and some cool ways I'm using it.
## What is FileBrowser?
FileBrowser is an open-source, single binary file manager with a clean web interface that lets you:
- Access and manage files from any device with a browser
- Share files with customizable permissions
- Edit files directly in the browser
- Perform basic file operations (copy, move, delete, upload, download)
- Search through your files and folders
The best part? It's lightweight (< 20MB), written in Go, and runs on pretty much anything - from a Raspberry Pi to your Kubernetes cluster.
## Getting Started with FileBrowser
### Option 1: Docker Deployment
For the Docker enthusiasts (like me), here's how to get FileBrowser up and running in seconds:
```bash
docker run -d \
--name filebrowser \
-v /path/to/your/files:/srv \
-v /path/to/filebrowser/database:/database \
-e PUID=$(id -u) \
-e PGID=$(id -g) \
-p 8080:80 \
filebrowser/filebrowser:latest
```
This will start FileBrowser on port 8080, with your files mounted at `/srv` inside the container.
### Option 2: Kubernetes Deployment with Helm
For my fellow Kubernetes fanatics, here's a simple Helm chart deployment:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: filebrowser
labels:
app: filebrowser
spec:
replicas: 1
selector:
matchLabels:
app: filebrowser
template:
metadata:
labels:
app: filebrowser
spec:
containers:
- name: filebrowser
image: filebrowser/filebrowser:latest
ports:
- containerPort: 80
volumeMounts:
- name: config
mountPath: /database
- name: data
mountPath: /srv
volumes:
- name: config
persistentVolumeClaim:
claimName: filebrowser-config
- name: data
persistentVolumeClaim:
claimName: filebrowser-data
---
apiVersion: v1
kind: Service
metadata:
name: filebrowser
spec:
type: ClusterIP
ports:
- port: 80
targetPort: 80
selector:
app: filebrowser
```
Don't forget to create the necessary PVCs for your configuration and data.
## Configuring FileBrowser
Once you have FileBrowser running, you can access it at `http://your-server:8080`. The default credentials are:
- Username: `admin`
- Password: `admin`
**Pro tip**: Change these immediately! You can do this through the UI or by using the FileBrowser CLI.
### Custom Configuration
You can customize FileBrowser by modifying the configuration file. Here's what my config looks like:
```json
{
"port": 80,
"baseURL": "",
"address": "",
"log": "stdout",
"database": "/database/filebrowser.db",
"root": "/srv",
"auth": {
"method": "json",
"header": ""
},
"branding": {
"name": "LaForceIT Files",
"disableExternal": false,
"files": "",
"theme": "dark"
},
"cors": {
"enabled": false,
"credentials": false,
"allowedHosts": []
}
}
```
## Securing FileBrowser
Security is crucial, especially when hosting a file manager. Here's how I secure my FileBrowser instance:
1. **Reverse Proxy**: I put FileBrowser behind a reverse proxy (Traefik) with SSL encryption.
2. **Authentication**: I've integrated with my Authelia setup for SSO across my services.
3. **User Isolation**: I create separate users with their own root directories to keep things isolated.
Here's a sample Traefik configuration for FileBrowser:
```yaml
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: filebrowser
namespace: default
spec:
entryPoints:
- websecure
routes:
- match: Host(`files.yourdomain.com`)
kind: Rule
services:
- name: filebrowser
port: 80
middlewares:
- name: auth-middleware
tls:
certResolver: letsencrypt
```
## My Top 5 FileBrowser Use Cases
1. **Home Media Management**: I organize my photos, music, and video collections.
2. **Document Repository**: A central place for important documents that I can access from anywhere.
3. **Code Snippet Library**: I keep commonly used code snippets organized by language and project.
4. **Backup Verification**: An easy way to browse my automated backups to verify they're working.
5. **Sharing Files**: When I need to share large files with friends or family, I create a temporary user with limited access.
## Power User Tips
Here are some tricks I've learned along the way:
- **Keyboard Shortcuts**: Press `?` in the UI to see all available shortcuts.
- **Custom Branding**: Personalize the look and feel by setting a custom name and logo in the config.
- **Multiple Instances**: Run multiple instances for different purposes (e.g., one for media, one for documents).
- **Command Runner**: Use the built-in command runner to execute shell scripts on your server.
## Wrapping Up
FileBrowser has become an essential part of my home lab setup. It's lightweight, fast, and just gets the job done without unnecessary complexity. Whether you're a home lab enthusiast or just looking for a simple way to manage your files, FileBrowser is worth checking out.
What file management solution are you using? Let me know in the comments!
---
_This post was last updated on December 15, 2023 with the latest FileBrowser configuration options and security recommendations._

View File

@ -1,332 +0,0 @@
---
title: "Self-Hosting Git with Gitea: Your Own GitHub Alternative"
description: A comprehensive guide to setting up Gitea - a lightweight, self-hosted Git service that gives you full control over your code repositories.
pubDate: 2025-04-19
updatedDate: 2025-04-18
category: Services
tags:
- gitea
- git
- self-hosted
- devops
- kubernetes
heroImage: /blog/images/posts/prometheusk8.png
---
If you're a developer like me who values ownership and privacy, you've probably wondered if there's a way to get the convenience of GitHub or GitLab without handing over your code to a third party. Enter Gitea - a painless, self-hosted Git service written in Go that I've been using for my personal projects for the past year.
Let me walk you through setting up your own Gitea instance and show you why it might be the perfect addition to your development workflow.
## Why Gitea?
First, let's talk about why you might want to run your own Git server:
- **Complete control**: Your code, your server, your rules.
- **Privacy**: Keep sensitive projects completely private.
- **No limits**: Create as many private repositories as you want.
- **Lightweight**: Gitea runs smoothly on minimal hardware (even a Raspberry Pi).
- **GitHub-like experience**: Familiar interface with issues, pull requests, and more.
I've tried several self-hosted Git solutions, but Gitea strikes the perfect balance between features and simplicity. It's like the Goldilocks of Git servers - not too heavy, not too light, just right.
## Getting Started with Gitea
### Option 1: Docker Installation
The easiest way to get started with Gitea is using Docker. Here's a simple `docker-compose.yml` file to get you up and running:
```yaml
version: "3"
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=db:5432
- GITEA__database__NAME=gitea
- GITEA__database__USER=gitea
- GITEA__database__PASSWD=gitea_password
restart: always
volumes:
- ./gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "222:22"
depends_on:
- db
networks:
- gitea
db:
image: postgres:14
container_name: gitea-db
restart: always
environment:
- POSTGRES_USER=gitea
- POSTGRES_PASSWORD=gitea_password
- POSTGRES_DB=gitea
volumes:
- ./postgres:/var/lib/postgresql/data
networks:
- gitea
networks:
gitea:
external: false
```
Save this file and run:
```bash
docker-compose up -d
```
Your Gitea instance will be available at `http://localhost:3000`.
### Option 2: Kubernetes Deployment
For those running a Kubernetes cluster (like me), here's a basic manifest to deploy Gitea:
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: gitea-data
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: gitea
labels:
app: gitea
spec:
replicas: 1
selector:
matchLabels:
app: gitea
template:
metadata:
labels:
app: gitea
spec:
containers:
- name: gitea
image: gitea/gitea:latest
ports:
- containerPort: 3000
- containerPort: 22
volumeMounts:
- name: data
mountPath: /data
env:
- name: USER_UID
value: "1000"
- name: USER_GID
value: "1000"
volumes:
- name: data
persistentVolumeClaim:
claimName: gitea-data
---
apiVersion: v1
kind: Service
metadata:
name: gitea
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: 3000
name: web
- port: 22
targetPort: 22
name: ssh
selector:
app: gitea
```
Apply it with:
```bash
kubectl apply -f gitea.yaml
```
## Initial Configuration
After installation, you'll be greeted with Gitea's setup page. Here are the settings I recommend:
1. **Database Settings**: If you followed the Docker Compose example, your database is already configured.
2. **General Settings**:
- Set your site title (e.g., "LaForceIT Git")
- Disable user registration unless you're hosting for multiple people
- Enable caching to improve performance
3. **Admin Account**: Create your admin user with a strong password.
My configuration looks something like this:
```ini
[server]
DOMAIN = git.laforce.it
SSH_DOMAIN = git.laforce.it
ROOT_URL = https://git.laforce.it/
DISABLE_SSH = false
SSH_PORT = 22
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = true
[security]
INSTALL_LOCK = true
```
## Integrating with Your Development Workflow
Now that Gitea is running, here's how I integrate it into my workflow:
### 1. Adding Your SSH Key
First, add your SSH key to Gitea:
1. Go to Settings > SSH / GPG Keys
2. Click "Add Key"
3. Paste your public key and give it a name
### 2. Creating Your First Repository
1. Click the "+" button in the top right
2. Select "New Repository"
3. Fill in the details and initialize with a README if desired
### 3. Working with Your Repository
To clone your new repository:
```bash
git clone git@your-gitea-server:username/repo-name.git
```
Now you can work with it just like any Git repository:
```bash
cd repo-name
echo "# My awesome project" > README.md
git add README.md
git commit -m "Update README"
git push origin main
```
## Advanced Gitea Features
Gitea isn't just a basic Git server - it has several powerful features that I use daily:
### CI/CD with Gitea Actions
Gitea recently added support for Actions, which are compatible with GitHub Actions workflows. Here's a simple example:
```yaml
name: Go Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.20'
- name: Build
run: go build -v ./...
- name: Test
run: go test -v ./...
```
### Webhooks for Integration
I use webhooks to integrate Gitea with my deployment pipeline. Here's how to set up a simple webhook:
1. Navigate to your repository
2. Go to Settings > Webhooks > Add Webhook
3. Select "Gitea" or "Custom" depending on your needs
4. Enter the URL of your webhook receiver
5. Choose which events trigger the webhook
### Mirror Repositories
One of my favorite features is repository mirroring. I use this to keep a backup of important GitHub repositories:
1. Create a new repository
2. Go to Settings > Mirror Settings
3. Enter the URL of the repository you want to mirror
4. Set the sync interval
## Security Considerations
When self-hosting any service, security is a top priority. Here's how I secure my Gitea instance:
1. **Reverse Proxy**: I put Gitea behind Traefik with automatic SSL certificates.
2. **2FA**: Enable two-factor authentication for your admin account.
3. **Regular Backups**: I back up both the Gitea data directory and the database daily.
4. **Updates**: Keep Gitea updated to the latest version to get security fixes.
Here's a sample Traefik configuration for Gitea:
```yaml
apiVersion: traefik.containo.us/v1alpha1
kind: IngressRoute
metadata:
name: gitea
namespace: default
spec:
entryPoints:
- websecure
routes:
- match: Host(`git.yourdomain.com`)
kind: Rule
services:
- name: gitea
port: 3000
tls:
certResolver: letsencrypt
```
## Why I Switched from GitHub to Gitea
People often ask me why I bother with self-hosting when GitHub offers so much for free. Here are my reasons:
1. **Ownership**: No sudden changes in terms of service affecting my workflow.
2. **Privacy**: Some projects aren't meant for public hosting.
3. **Learning**: Managing my own services teaches me valuable skills.
4. **Integration**: It fits perfectly with my other self-hosted services.
5. **Performance**: Local Git operations are lightning-fast.
## Wrapping Up
Gitea has been a fantastic addition to my self-hosted infrastructure. It's reliable, lightweight, and provides all the features I need without the complexity of larger solutions like GitLab.
Whether you're a privacy enthusiast, a homelab tinkerer, or just someone who wants complete control over your code, Gitea is worth considering. The setup is straightforward, and the rewards are significant.
What about you? Are you self-hosting any of your development tools? Let me know in the comments!
---
_This post was last updated on January 18, 2024 with information about Gitea Actions and the latest configuration options._

View File

@ -1,169 +0,0 @@
---
title: GitOps with Flux CD
description: Implementing GitOps workflows on Kubernetes using Flux CD
pubDate: 2025-04-19
heroImage: /blog/images/posts/prometheusk8.png
category: devops
tags:
- kubernetes
- gitops
- flux
- ci-cd
- automation
readTime: 10 min read
---
# GitOps with Flux CD
GitOps is revolutionizing the way teams deploy and manage applications on Kubernetes. This guide will walk you through implementing a GitOps workflow using Flux CD, an open-source continuous delivery tool.
## What is GitOps?
GitOps is an operational framework that takes DevOps best practices used for application development such as version control, collaboration, compliance, and CI/CD, and applies them to infrastructure automation.
With GitOps:
- Git is the single source of truth for the desired state of your infrastructure
- Changes to the desired state are declarative and version controlled
- Approved changes are automatically applied to your infrastructure
## Why Flux CD?
Flux CD is a GitOps tool that ensures that your Kubernetes cluster matches the desired state specified in a Git repository. Key features include:
- Automated sync between your Git repository and cluster state
- Support for Kustomize, Helm, and plain Kubernetes manifests
- Multi-tenancy via RBAC
- Strong security practices, including image verification
## Installation
### Prerequisites
- A Kubernetes cluster (K3s, Kind, or any other distribution)
- kubectl configured to access your cluster
- A GitHub (or GitLab/Bitbucket) account and repository
### Installing Flux
1. Install the Flux CLI:
```bash
curl -s https://fluxcd.io/install.sh | sudo bash
```
2. Export your GitHub personal access token:
```bash
export GITHUB_TOKEN=<your-token>
```
3. Bootstrap Flux:
```bash
flux bootstrap github \
--owner=<your-github-username> \
--repository=<repository-name> \
--path=clusters/my-cluster \
--personal
```
## Setting Up Your First Application
1. Create a basic directory structure in your Git repository:
```
└── clusters/
└── my-cluster/
├── flux-system/ # Created by bootstrap
└── apps/
└── podinfo/
├── namespace.yaml
├── deployment.yaml
└── service.yaml
```
2. Create a Flux Kustomization to deploy your app:
```yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1beta2
kind: Kustomization
metadata:
name: podinfo
namespace: flux-system
spec:
interval: 5m0s
path: ./clusters/my-cluster/apps/podinfo
prune: true
sourceRef:
kind: GitRepository
name: flux-system
```
3. Commit and push your changes, and Flux will automatically deploy your application!
## Advanced Features
### Automated Image Updates
Flux can automatically update your deployments when new images are available:
```yaml
apiVersion: image.toolkit.fluxcd.io/v1beta1
kind: ImageRepository
metadata:
name: podinfo
namespace: flux-system
spec:
image: ghcr.io/stefanprodan/podinfo
interval: 1m0s
---
apiVersion: image.toolkit.fluxcd.io/v1beta1
kind: ImagePolicy
metadata:
name: podinfo
namespace: flux-system
spec:
imageRepositoryRef:
name: podinfo
policy:
semver:
range: 6.x.x
```
### Working with Helm Charts
Flux makes it easy to manage Helm releases:
```yaml
apiVersion: source.toolkit.fluxcd.io/v1beta2
kind: HelmRepository
metadata:
name: bitnami
namespace: flux-system
spec:
interval: 30m
url: https://charts.bitnami.com/bitnami
---
apiVersion: helm.toolkit.fluxcd.io/v2beta1
kind: HelmRelease
metadata:
name: redis
namespace: flux-system
spec:
interval: 5m
chart:
spec:
chart: redis
version: "16.x"
sourceRef:
kind: HelmRepository
name: bitnami
values:
architecture: standalone
```
## Conclusion
Flux CD provides a powerful, secure, and flexible platform for implementing GitOps workflows. By following this guide, you'll be well on your way to managing your Kubernetes infrastructure using GitOps principles.
Stay tuned for more advanced GitOps patterns and best practices!

View File

@ -1,18 +0,0 @@
---
title: Setting Up a K3s Kubernetes Cluster
pubDate: 2025-04-19
description: A comprehensive guide to setting up and configuring a lightweight K3s Kubernetes cluster for your home lab
category: Infrastructure
tags:
- kubernetes
- k3s
- infrastructure
- homelab
- containers
draft: false
heroImage:
---
# Setting Up a K3s Kubernetes Cluster
Coming soon...

View File

@ -1,90 +0,0 @@
---
title: K3s Installation Guide
description: A comprehensive guide to installing and configuring K3s for your home lab
pubDate: 2025-04-19
heroImage: /blog/images/posts/k3installation.png
category: kubernetes
tags:
- kubernetes
- k3s
- homelab
- tutorial
readTime: 8 min read
---
# K3s Installation Guide
K3s is a lightweight Kubernetes distribution designed for resource-constrained environments, perfect for home labs and edge computing. This guide will walk you through the installation process from start to finish.
## Prerequisites
- A machine running Linux (Ubuntu 20.04+ recommended)
- Minimum 1GB RAM (2GB+ recommended for multi-workload clusters)
- 20GB+ of disk space
- Root access or sudo privileges
## Basic Installation
The simplest way to install K3s is using the installation script:
```bash
curl -sfL https://get.k3s.io | sh -
```
This will install K3s as a server and start the service. To verify it's running:
```bash
sudo k3s kubectl get node
```
## Advanced Installation Options
### Master Node with Custom Options
For more control, you can use environment variables:
```bash
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable=traefik" sh -
```
### Adding Worker Nodes
1. On your master node, get the node token:
```bash
sudo cat /var/lib/rancher/k3s/server/node-token
```
2. On each worker node, run:
```bash
curl -sfL https://get.k3s.io | K3S_URL=https://<master-node-ip>:6443 K3S_TOKEN=<node-token> sh -
```
## Accessing the Cluster
After installation, the kubeconfig file is written to `/etc/rancher/k3s/k3s.yaml`. To use kubectl externally:
1. Copy this file to your local machine
2. Set the KUBECONFIG environment variable:
```bash
export KUBECONFIG=/path/to/k3s.yaml
```
## Uninstalling K3s
If you need to uninstall:
- On server nodes: `/usr/local/bin/k3s-uninstall.sh`
- On agent nodes: `/usr/local/bin/k3s-agent-uninstall.sh`
## Next Steps
Now that you have K3s running, you might want to:
- Deploy your first application
- Set up persistent storage
- Configure ingress for external access
Stay tuned for more guides on these topics!

View File

@ -1,289 +0,0 @@
---
title: Setting Up Prometheus Monitoring in Kubernetes
description: A comprehensive guide to implementing Prometheus monitoring in your Kubernetes cluster
pubDate: 2025-04-19
heroImage: /blog/images/posts/prometheusk8.png
category: devops
tags:
- kubernetes
- monitoring
- prometheus
- grafana
- observability
readTime: 9 min read
---
# Setting Up Prometheus Monitoring in Kubernetes
Effective monitoring is crucial for maintaining a healthy Kubernetes environment. Prometheus has become the de facto standard for metrics collection and alerting in cloud-native environments. This guide will walk you through setting up a complete Prometheus monitoring stack in your Kubernetes cluster.
## Why Prometheus?
Prometheus offers several advantages for Kubernetes monitoring:
- **Pull-based architecture**: Simplifies configuration and security
- **Powerful query language (PromQL)**: For flexible data analysis
- **Service discovery**: Automatically finds targets in dynamic environments
- **Rich ecosystem**: Wide range of exporters and integrations
- **CNCF graduated project**: Strong community and vendor support
## Components of the Monitoring Stack
We'll set up a complete monitoring stack consisting of:
1. **Prometheus**: Core metrics collection and storage
2. **Alertmanager**: Handles alerts and notifications
3. **Grafana**: Visualization and dashboards
4. **Node Exporter**: Collects host-level metrics
5. **kube-state-metrics**: Collects Kubernetes state metrics
6. **Prometheus Operator**: Simplifies Prometheus management in Kubernetes
## Prerequisites
- A running Kubernetes cluster (K3s, EKS, GKE, etc.)
- kubectl configured to access your cluster
- Helm 3 installed
## Installation Using Helm
The easiest way to deploy Prometheus is using the kube-prometheus-stack Helm chart, which includes all the components mentioned above.
### 1. Add the Prometheus Community Helm Repository
```bash
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
```
### 2. Create a Namespace for Monitoring
```bash
kubectl create namespace monitoring
```
### 3. Configure Values
Create a `values.yaml` file with your custom configuration:
```yaml
prometheus:
prometheusSpec:
retention: 15d
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 2Gi
cpu: 500m
storageSpec:
volumeClaimTemplate:
spec:
storageClassName: standard
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
alertmanager:
alertmanagerSpec:
storage:
volumeClaimTemplate:
spec:
storageClassName: standard
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
grafana:
persistence:
enabled: true
storageClassName: standard
size: 10Gi
adminPassword: "prom-operator" # Change this!
nodeExporter:
enabled: true
kubeStateMetrics:
enabled: true
```
### 4. Install the Helm Chart
```bash
helm install prometheus prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--values values.yaml
```
### 5. Verify the Installation
Check that all the pods are running:
```bash
kubectl get pods -n monitoring
```
## Accessing the UIs
By default, the components don't have external access. You can use port-forwarding to access them:
### Prometheus UI
```bash
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090
```
Then access Prometheus at http://localhost:9090
### Grafana
```bash
kubectl port-forward -n monitoring svc/prometheus-grafana 3000:80
```
Then access Grafana at http://localhost:3000 (default credentials: admin/prom-operator)
### Alertmanager
```bash
kubectl port-forward -n monitoring svc/prometheus-alertmanager 9093:9093
```
Then access Alertmanager at http://localhost:9093
## For Production: Exposing Services
For production environments, you'll want to set up proper ingress. Here's an example using a basic Ingress resource:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: prometheus-ingress
namespace: monitoring
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
rules:
- host: prometheus.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus-operated
port:
number: 9090
- host: grafana.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus-grafana
port:
number: 80
- host: alertmanager.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus-alertmanager
port:
number: 9093
```
## Configuring Alerting
### 1. Set Up Alert Rules
Alert rules can be created using the PrometheusRule custom resource:
```yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: node-alerts
namespace: monitoring
labels:
release: prometheus
spec:
groups:
- name: node.rules
rules:
- alert: HighNodeCPU
expr: instance:node_cpu_utilisation:rate1m > 0.8
for: 5m
labels:
severity: warning
annotations:
summary: "High CPU usage on {{ $labels.instance }}"
description: "CPU usage is above 80% for 5 minutes on node {{ $labels.instance }}"
```
### 2. Configure Alert Receivers
Configure Alertmanager to send notifications by creating a Secret with your configuration:
```yaml
apiVersion: v1
kind: Secret
metadata:
name: alertmanager-prometheus-alertmanager
namespace: monitoring
stringData:
alertmanager.yaml: |
global:
resolve_timeout: 5m
slack_api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'
route:
group_by: ['job', 'alertname', 'namespace']
group_wait: 30s
group_interval: 5m
repeat_interval: 12h
receiver: 'slack-notifications'
routes:
- receiver: 'slack-notifications'
matchers:
- severity =~ "warning|critical"
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts'
send_resolved: true
title: '{{ template "slack.default.title" . }}'
text: '{{ template "slack.default.text" . }}'
type: Opaque
```
## Custom Dashboards
Grafana comes pre-configured with several useful dashboards, but you can import more from [Grafana.com](https://grafana.com/grafana/dashboards/).
Some recommended dashboard IDs to import:
- 1860: Node Exporter Full
- 12740: Kubernetes Monitoring
- 13332: Prometheus Stats
## Troubleshooting
### Common Issues
1. **Insufficient Resources**: Prometheus can be resource-intensive. Adjust resource limits if pods are being OOMKilled.
2. **Storage Issues**: Ensure your storage class supports the access modes you've configured.
3. **ServiceMonitor not working**: Check that the label selectors match your services.
## Conclusion
You now have a fully functional Prometheus monitoring stack for your Kubernetes cluster. This setup provides comprehensive metrics collection, visualization, and alerting capabilities essential for maintaining a healthy and performant cluster.
In future articles, we'll explore advanced topics like custom exporters, recording rules for performance, and integrating with other observability tools like Loki for logs and Tempo for traces.

View File

@ -1,191 +0,0 @@
---
title: Complete Proxmox VE Setup Guide
description: A step-by-step guide to setting up Proxmox VE for your home lab virtualization needs
pubDate: 2025-04-19
heroImage: /blog/images/posts/prometheusk8.png
category: infrastructure
tags:
- proxmox
- virtualization
- homelab
- infrastructure
readTime: 12 min read
---
# Complete Proxmox VE Setup Guide
Proxmox Virtual Environment (VE) is a complete open-source server management platform for enterprise virtualization. It tightly integrates KVM hypervisor and LXC containers, software-defined storage and networking functionality, on a single platform. This guide will walk you through installing and configuring Proxmox VE for your home lab.
## Why Choose Proxmox VE?
- **Open Source**: Free to use with optional paid enterprise support
- **Full-featured**: Combines KVM hypervisor and LXC containers
- **Web Interface**: Easy-to-use management interface
- **Clustering**: Built-in high availability features
- **Storage Flexibility**: Support for local, SAN, NFS, Ceph, and more
## Hardware Requirements
- 64-bit CPU with virtualization extensions (Intel VT-x/AMD-V)
- At least 2GB RAM (8GB+ recommended)
- Hard drive for OS installation (SSD recommended)
- Additional storage for VMs and containers
- Network interface card
## Installation
### 1. Prepare for Installation
1. Download the Proxmox VE ISO from [proxmox.com/downloads](https://www.proxmox.com/downloads)
2. Create a bootable USB drive using tools like Rufus, Etcher, or dd
3. Ensure virtualization is enabled in your BIOS/UEFI
### 2. Install Proxmox VE
1. Boot from the USB drive
2. Select "Install Proxmox VE"
3. Accept the EULA
4. Select the target hard drive (this will erase all data on the drive)
5. Configure country, time zone, and keyboard layout
6. Set a strong root password and provide an email address
7. Configure network settings:
- Enter a hostname (FQDN format: proxmox.yourdomain.local)
- IP address, netmask, gateway
- DNS server
8. Review the settings and confirm to start the installation
9. Once completed, remove the USB drive and reboot
### 3. Initial Configuration
Access the web interface by navigating to `https://<your-proxmox-ip>:8006` in your browser. Log in with:
- Username: root
- Password: (the one you set during installation)
## Post-Installation Tasks
### 1. Update Proxmox VE
```bash
apt update
apt dist-upgrade
```
### 2. Remove Subscription Notice (Optional)
For home lab use, you can remove the subscription notice:
```bash
echo "DPkg::Post-Invoke { \"dpkg -V proxmox-widget-toolkit | grep -q '/proxmoxlib\.js$'; if [ \$? -eq 1 ]; then { echo 'Removing subscription nag from UI...'; sed -i '/.*data\.status.*subscription.*/d' /usr/share/javascript/proxmox-widget-toolkit/proxmoxlib.js; }; fi\"; };" > /etc/apt/apt.conf.d/no-subscription-warning
```
### 3. Configure Storage
#### Local Storage
By default, Proxmox VE creates several storage locations:
- **local**: For ISO images, container templates, and snippets
- **local-lvm**: For VM disk images
To add a new storage, go to Datacenter > Storage > Add:
- For local directories: Select "Directory"
- For network storage: Select "NFS" or "CIFS"
- For block storage: Select "LVM", "LVM-Thin", or "ZFS"
#### ZFS Storage Pool (Recommended)
ZFS offers excellent performance and data protection:
```bash
# Create a ZFS pool using available disks
zpool create -f rpool /dev/sdb /dev/sdc
# Add the pool to Proxmox
pvesm add zfspool rpool -pool rpool
```
### 4. Set Up Networking
#### Network Bridges
Proxmox VE creates a default bridge (vmbr0) during installation. To add more:
1. Go to Node > Network > Create > Linux Bridge
2. Configure the bridge:
- Name: vmbr1
- IP address/CIDR: 192.168.1.1/24 (or leave empty for unmanaged bridge)
- Bridge ports: (physical interface, e.g., eth1)
#### VLAN Configuration
For VLAN support:
1. Ensure the bridge has VLAN awareness enabled
2. In VM network settings, specify VLAN tags
## Creating Virtual Machines and Containers
### Virtual Machines (KVM)
1. Go to Create VM
2. Fill out the wizard:
- General: Name, Resource Pool
- OS: ISO image, type, and version
- System: BIOS/UEFI, Machine type
- Disks: Size, format, storage location
- CPU: Cores, type
- Memory: RAM size
- Network: Bridge, model
3. Click Finish to create the VM
### Containers (LXC)
1. Go to Create CT
2. Fill out the wizard:
- General: Hostname, Password
- Template: Select from available templates
- Disks: Size, storage location
- CPU: Cores
- Memory: RAM size
- Network: IP address, bridge
- DNS: DNS servers
3. Click Finish to create the container
## Backup Configuration
### Setting Up Backups
1. Go to Datacenter > Backup
2. Add a new backup job:
- Select storage location
- Set schedule (daily, weekly, etc.)
- Choose VMs/containers to back up
- Configure compression and mode
## Performance Tuning
### CPU
For VMs that need consistent performance:
- Set CPU type to "host" for best performance
- Reserve CPU cores for critical VMs
- Use CPU pinning for high-performance workloads
### Memory
- Enable KSM (Kernel Same-page Merging) for better memory usage
- Set appropriate memory ballooning for VMs
### Storage
- Use SSDs for VM disks when possible
- Enable write-back caching for improved performance
- Consider ZFS for important data with appropriate RAM allocation
## Conclusion
Proxmox VE is a powerful, flexible virtualization platform perfect for home labs. With its combination of virtual machines and containers, you can build a versatile lab environment for testing, development, and running production services.
After following this guide, you should have a fully functional Proxmox VE server ready to host your virtual infrastructure. In future articles, we'll explore advanced topics like clustering, high availability, and integration with other infrastructure tools.

View File

@ -1,23 +0,0 @@
#!/bin/bash
# Script to push blog content to Gitea with proper symlink handling
# This ensures actual content is pushed, not just symlinks
# Navigate to the blog repository
cd ~/Projects/laforceit-blog
# Configure git to handle symlinks correctly
git config --local core.symlinks true
# Add all changes, dereferencing symlinks to include the actual content
git add -A --dereference
# Get commit message from argument or use default
COMMIT_MSG=${1:-"Update blog content"}
# Commit changes
git commit -m "$COMMIT_MSG"
# Push to the repository
git push origin main

View File

@ -1,416 +0,0 @@
---
title: Building a Digital Garden with Quartz, Obsidian, and Astro
description: How to transform your Obsidian notes into a beautiful, connected public digital garden using Quartz and Astro
pubDate: 2025-04-18
updatedDate: 2025-04-19
category: Services
tags:
- quartz
- obsidian
- digital-garden
- knowledge-management
- astro
heroImage: /blog/images/posts/prometheusk8.png
---
I've been taking digital notes for decades now. From simple `.txt` files to OneNote, Evernote, Notion, and now Obsidian. But for years, I've been wrestling with a question: how do I share my knowledge with others in a way that preserves the connections between ideas?
Enter [Quartz](https://quartz.jzhao.xyz/) - an open-source static site generator designed specifically for transforming Obsidian vaults into interconnected digital gardens. I've been using it with Astro to power this very blog, and today I want to show you how you can do the same.
## What is a Digital Garden?
Before we dive into the technical stuff, let's talk about what a digital garden actually is. Unlike traditional blogs organized chronologically, a digital garden is more like... well, a garden! It's a collection of interconnected notes that grow over time.
Think of each note as a plant. Some are seedlings (early ideas), some are in full bloom (well-developed thoughts), and others might be somewhere in between. The beauty of a digital garden is that it evolves organically, with connections forming between ideas just as they do in our brains.
## Why Quartz + Obsidian + Astro?
I settled on this stack after trying numerous solutions, and here's why:
- **Obsidian**: Provides a fantastic editing experience with bi-directional linking, graph views, and markdown support.
- **Quartz**: Transforms Obsidian vaults into interconnected websites with minimal configuration.
- **Astro**: Adds flexibility for custom layouts, components, and integrations not available in Quartz alone.
It's a match made in heaven - I get the best note-taking experience with Obsidian, the connection-preserving features of Quartz, and the full power of a modern web framework with Astro.
## Setting Up Your Digital Garden
Let's walk through how to set this up step by step.
### Step 1: Install Obsidian and Create a Vault
If you haven't already, [download Obsidian](https://obsidian.md/) and create a new vault for your digital garden content. I recommend having a separate vault for public content to keep it clean.
### Step 2: Set Up Quartz
Quartz is essentially a template for your Obsidian content. Here's how to get it running:
```bash
# Clone the Quartz repository
git clone https://github.com/jackyzha0/quartz.git
cd quartz
# Install dependencies
npm install
# Copy your Obsidian vault content to the content folder
# (or symlink it, which is what I do)
ln -s /path/to/your/obsidian/vault content
```
Once installed, you can customize your Quartz configuration in the `quartz.config.ts` file. Here's a snippet of mine:
```typescript
// quartz.config.ts
const config: QuartzConfig = {
configuration: {
pageTitle: "LaForceIT Digital Garden",
enableSPA: true,
enablePopovers: true,
analytics: {
provider: "plausible",
},
baseUrl: "blog.laforceit.com",
ignorePatterns: ["private", "templates", ".obsidian"],
theme: {
typography: {
header: "Space Grotesk",
body: "Space Grotesk",
code: "JetBrains Mono",
},
colors: {
lightMode: {
light: "#fafafa",
lightgray: "#e5e5e5",
gray: "#b8b8b8",
darkgray: "#4e4e4e",
dark: "#2b2b2b",
secondary: "#284b63",
tertiary: "#84a59d",
highlight: "rgba(143, 159, 169, 0.15)",
},
darkMode: {
light: "#050a18",
lightgray: "#0f172a",
gray: "#1e293b",
darkgray: "#94a3b8",
dark: "#e2e8f0",
secondary: "#06b6d4",
tertiary: "#3b82f6",
highlight: "rgba(6, 182, 212, 0.15)",
},
},
},
},
plugins: {
transformers: [
// plugins here
],
filters: [
// filters here
],
emitters: [
// emitters here
],
}
};
```
### Step 3: Integrating with Astro
Now, where Quartz ends and Astro begins is where the magic really happens. Here's how I integrated them:
1. Create a new Astro project:
```bash
npm create astro@latest my-digital-garden
cd my-digital-garden
```
2. Set up your Astro project structure:
```
my-digital-garden/
├── public/
├── src/
│ ├── components/
│ ├── layouts/
│ ├── pages/
│ │ └── index.astro
│ └── content/ (this will point to your Quartz content)
├── astro.config.mjs
└── package.json
```
3. Configure Astro to work with Quartz's output:
```typescript
// astro.config.mjs
import { defineConfig } from 'astro/config';
export default defineConfig({
integrations: [
// Your integrations here
],
markdown: {
remarkPlugins: [
// The same remark plugins used in Quartz
],
rehypePlugins: [
// The same rehype plugins used in Quartz
]
}
});
```
4. Create a component for the Obsidian graph view (similar to what I use on this blog):
```astro
---
// Graph.astro
---
<div class="graph-container">
<div class="graph-visualization">
<div class="graph-overlay"></div>
<div class="graph-nodes" id="obsidian-graph"></div>
</div>
<div class="graph-caption">Interactive visualization of interconnected notes</div>
</div>
<script src="/blog/scripts/neural-network.js" defer></script>
<style>
.graph-container {
width: 100%;
height: 400px;
position: relative;
border-radius: 0.75rem;
overflow: hidden;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(56, 189, 248, 0.2);
}
.graph-visualization {
width: 100%;
height: 100%;
position: relative;
}
.graph-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(
circle at center,
transparent 0%,
rgba(15, 23, 42, 0.5) 100%
);
z-index: 1;
pointer-events: none;
}
.graph-nodes {
width: 100%;
height: 100%;
position: relative;
z-index: 2;
}
.graph-caption {
position: absolute;
bottom: 0.75rem;
left: 0;
width: 100%;
text-align: center;
color: rgba(148, 163, 184, 0.8);
font-size: 0.875rem;
z-index: 3;
}
</style>
```
## Writing Content for Your Digital Garden
Now that you have the technical setup, let's talk about how to structure your content. Here's how I approach it:
### 1. Use Meaningful Frontmatter
Each note should have frontmatter that helps organize and categorize it:
```markdown
---
title: "Building a Digital Garden"
description: "How to create a connected knowledge base with Obsidian and Quartz"
pubDate: 2023-11-05
updatedDate: 2024-02-10
category: "Knowledge Management"
tags: ["digital-garden", "obsidian", "quartz", "notes"]
---
```
### 2. Create Meaningful Connections
The power of a digital garden comes from connections. Use Obsidian's `[[wiki-links]]` liberally to connect related concepts:
```markdown
I'm using the [[quartz-digital-garden|Quartz framework]] to power my digital garden. It works well with [[obsidian-setup|my Obsidian workflow]].
```
### 3. Use Consistent Structure for Your Notes
I follow a template for most of my notes to maintain consistency:
- Brief introduction/definition
- Why it matters
- Key concepts
- Examples or code snippets
- References to related notes
- External resources
### 4. Leverage Obsidian Features
Make use of Obsidian's unique features that Quartz preserves:
- **Callouts** for highlighting important information
- **Dataview** for creating dynamic content (if using the Dataview plugin)
- **Graphs** to visualize connections
## Deploying Your Digital Garden
Here's how I deploy my digital garden to Cloudflare Pages:
1. **Build Configuration**:
```javascript
// Build command
astro build
// Output directory
dist
```
2. **Automated Deployment from Git**:
I have a GitHub action that publishes my content whenever I push changes:
```yaml
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: true
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Install Dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: digital-garden
directory: dist
```
## My Digital Garden Workflow
Here's my actual workflow for maintaining my digital garden:
1. **Daily note-taking** in Obsidian (private vault)
2. **Weekly review** where I refine notes and connections
3. **Publishing prep** where I move polished notes to my public vault
4. **Git commit and push** which triggers the deployment
The beauty of this system is that my private thinking and public sharing use the same tools and formats, reducing friction between capturing thoughts and sharing them.
## Adding Interactive Elements
One of my favorite parts of using Astro with Quartz is that I can add interactive elements to my digital garden. For example, the neural graph visualization you see on this blog:
```javascript
// Simplified version of the neural network graph code
document.addEventListener('DOMContentLoaded', () => {
const container = document.querySelector('.graph-nodes');
if (!container) return;
// Configuration
const CONNECTION_DISTANCE = 30;
const MIN_NODE_SIZE = 4;
const MAX_NODE_SIZE = 12;
// Fetch blog data from the same origin
async function fetchQuartzData() {
// In production, fetch from your actual API
return {
nodes: [
{
id: 'digital-garden',
title: 'Digital Garden',
tags: ['knowledge-management', 'notes'],
category: 'concept'
},
// More nodes here
],
links: [
{ source: 'digital-garden', target: 'obsidian' },
// More links here
]
};
}
// Create the visualization
fetchQuartzData().then(graphData => {
// Create nodes and connections
// (implementation details omitted for brevity)
});
});
```
## Tips for a Successful Digital Garden
After maintaining my digital garden for over a year, here are my top tips:
1. **Start small** - Begin with a few well-connected notes rather than trying to publish everything at once.
2. **Focus on connections** - The value is in the links between notes, not just the notes themselves.
3. **Embrace imperfection** - Digital gardens are meant to grow and evolve; they're never "finished."
4. **Build in public** - Share your process and learnings as you go.
5. **Use consistent formatting** - Makes it easier for readers to navigate your garden.
## The Impact of My Digital Garden
Since starting my digital garden, I've experienced several unexpected benefits:
- **Clearer thinking** - Writing for an audience forces me to clarify my thoughts.
- **Unexpected connections** - I've discovered relationships between ideas I hadn't noticed before.
- **Community building** - I've connected with others interested in the same topics.
- **Learning accountability** - Publishing regularly motivates me to keep learning.
## Wrapping Up
Building a digital garden with Quartz, Obsidian, and Astro has transformed how I learn and share knowledge. It's become so much more than a blog - it's a living representation of my thinking that grows more valuable with each connection I make.
If you're considering starting your own digital garden, I hope this guide gives you a solid foundation. Remember, the best garden is the one you actually tend to, so start simple and let it grow naturally over time.
What about you? Are you maintaining a digital garden or thinking about starting one? I'd love to hear about your experience in the comments!
---
_This post was last updated on February 10, 2024 with information about the latest Quartz configuration options and integration with Astro._

View File

@ -1,328 +0,0 @@
---
title: "Managing Kubernetes with Rancher: The Home Lab Way"
description: How to set up, configure, and get the most out of Rancher for managing your home Kubernetes clusters
pubDate: 2025-04-19
updatedDate: 2025-04-18
category: Services
tags:
- rancher
- kubernetes
- k3s
- devops
- containers
heroImage: /blog/images/posts/prometheusk8.png
---
I've been running Kubernetes at home for years now, and I've tried just about every management tool out there. From kubectl and a bunch of YAML files to various dashboards and UIs, I've experimented with it all. But the one tool that's been a constant in my home lab journey is [Rancher](https://rancher.com/) - a complete container management platform that makes Kubernetes management almost... dare I say it... enjoyable?
Today, I want to walk you through setting up Rancher in your home lab and show you some of the features that have made it indispensable for me.
## What is Rancher and Why Should You Care?
Rancher is an open-source platform for managing Kubernetes clusters. Think of it as mission control for all your container workloads. It gives you:
- A unified interface for managing multiple clusters (perfect if you're running different K8s distros)
- Simplified deployment of applications via apps & marketplace
- Built-in monitoring, logging, and alerting
- User management and role-based access control
- A clean, intuitive UI that's actually useful (rare in the Kubernetes world!)
If you're running even a single Kubernetes cluster at home, Rancher can save you countless hours of typing `kubectl` commands and editing YAML files by hand.
## Setting Up Rancher in Your Home Lab
There are several ways to deploy Rancher, but I'll focus on two approaches that work well for home labs.
### Option 1: Docker Deployment (Quickstart)
The fastest way to get up and running is with Docker:
```bash
docker run -d --restart=unless-stopped \
-p 80:80 -p 443:443 \
--privileged \
rancher/rancher:latest
```
That's it! Navigate to `https://your-server-ip` and you'll be prompted to set a password and server URL.
But while this method is quick, I prefer the next approach for a more production-like setup.
### Option 2: Installing Rancher on K3s
My preferred method is to run Rancher on a lightweight Kubernetes distribution like K3s. This gives you better reliability and easier upgrades.
First, install K3s:
```bash
curl -sfL https://get.k3s.io | sh -
```
Next, install cert-manager (required for Rancher to manage certificates):
```bash
kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.12.2/cert-manager.yaml
```
Then, install Rancher using Helm:
```bash
helm repo add rancher-stable https://releases.rancher.com/server-charts/stable
helm repo update
kubectl create namespace cattle-system
helm install rancher rancher-stable/rancher \
--namespace cattle-system \
--set hostname=rancher.yourdomain.com \
--set bootstrapPassword=admin
```
Depending on your home lab setup, you might want to use a load balancer or ingress controller. I use Traefik, which comes pre-installed with K3s:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: rancher
namespace: cattle-system
spec:
rules:
- host: rancher.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: rancher
port:
number: 80
tls:
- hosts:
- rancher.yourdomain.com
secretName: rancher-tls
```
## Importing Your Existing Clusters
Once Rancher is running, you can import your existing Kubernetes clusters. This is my favorite part because it doesn't require you to rebuild anything.
1. In the Rancher UI, go to "Cluster Management"
2. Click "Import Existing"
3. Choose a name for your cluster
4. Copy the provided kubectl command and run it on your existing cluster
Rancher will install its agent on your cluster and begin managing it. Magic!
## Setting Up Monitoring
Rancher makes it dead simple to deploy Prometheus and Grafana for monitoring:
1. From your cluster's dashboard, go to "Apps"
2. Select "Monitoring" from the Charts
3. Install with default settings (or customize as needed)
In minutes, you'll have a full monitoring stack with pre-configured dashboards for nodes, pods, workloads, and more.
Here's what my Grafana dashboard looks like for my home K8s cluster:
![Rancher Grafana Dashboard](/blog/images/services/rancher-monitoring.jpg)
## Creating Deployments Through the UI
While I'm a big fan of GitOps and declarative deployments, sometimes you just want to quickly spin up a container without writing YAML. Rancher makes this painless:
1. Go to your cluster
2. Select "Workload > Deployments"
3. Click "Create"
4. Fill in the form with your container details
You get a nice UI for setting environment variables, volumes, health checks, and more. Once you're happy with it, Rancher generates and applies the YAML behind the scenes.
## Rancher Fleet for GitOps
One of the newer features I love is Fleet, Rancher's GitOps engine. It allows you to manage deployments across clusters using Git repositories:
```yaml
# Example fleet.yaml
defaultNamespace: monitoring
helm:
releaseName: prometheus
repo: https://prometheus-community.github.io/helm-charts
chart: kube-prometheus-stack
version: 39.4.0
values:
grafana:
adminPassword: ${GRAFANA_PASSWORD}
targets:
- name: prod
clusterSelector:
matchLabels:
environment: production
- name: dev
clusterSelector:
matchLabels:
environment: development
helm:
values:
resources:
limits:
memory: 1Gi
requests:
memory: 512Mi
```
With Fleet, I maintain a Git repository with all my deployments, and Rancher automatically applies them to the appropriate clusters. When I push changes, they're automatically deployed - proper GitOps!
## Rancher for Projects and Teams
If you're working with a team or want to compartmentalize your applications, Rancher's projects feature is fantastic:
1. Create different projects within a cluster (e.g., "Media," "Home Automation," "Development")
2. Assign namespaces to projects
3. Set resource quotas for each project
4. Create users and assign them to projects with specific permissions
This way, you can give friends or family members access to specific applications without worrying about them breaking your critical services.
## Advanced: Custom Cluster Templates
As my home lab grew, I started using Rancher's cluster templates to ensure consistency across my Kubernetes installations:
```yaml
apiVersion: management.cattle.io/v3
kind: ClusterTemplate
metadata:
name: homelab-standard
spec:
displayName: HomeStack Standard
revisionName: homelab-standard-v1
members:
- accessType: owner
userPrincipalName: user-abc123
template:
spec:
rancherKubernetesEngineConfig:
services:
etcd:
backupConfig:
enabled: true
intervalHours: 12
retention: 6
kubeApi:
auditLog:
enabled: true
network:
plugin: canal
monitoring:
provider: metrics-server
addons: |-
apiVersion: v1
kind: Namespace
metadata:
name: cert-manager
---
apiVersion: v1
kind: Namespace
metadata:
name: ingress-nginx
```
## My Top Rancher Tips
After years of using Rancher, here are my top tips:
1. **Use the Rancher CLI**: For repetitive tasks, the CLI is faster than the UI:
```bash
rancher login https://rancher.yourdomain.com --token token-abc123
rancher kubectl get nodes
```
2. **Set Up External Authentication**: Connect Rancher to your identity provider (I use GitHub):
```yaml
# Sample GitHub auth config
apiVersion: management.cattle.io/v3
kind: AuthConfig
metadata:
name: github
type: githubConfig
properties:
enabled: true
clientId: your-github-client-id
clientSecret: your-github-client-secret
allowedPrincipals:
- github_user://your-github-username
- github_org://your-github-org
```
3. **Create Node Templates**: If you're using RKE, save node templates for quick cluster expansion.
4. **Use App Templates**: Save your common applications as templates for quick deployment.
5. **Set Up Alerts**: Configure alerts for node health, pod failures, and resource constraints.
## Dealing with Common Rancher Issues
Even the best tools have their quirks. Here are some issues I've encountered and how I solved them:
### Issue: Rancher UI Becomes Slow
If your Rancher UI starts lagging, check your browser's local storage. The Rancher UI caches a lot of data, which can build up over time:
```javascript
// Run this in your browser console while on the Rancher page
localStorage.clear()
```
### Issue: Certificate Errors After DNS Changes
If you change your domain or DNS settings, Rancher certificates might need to be regenerated:
```bash
kubectl -n cattle-system delete secret tls-rancher-ingress
kubectl -n cattle-system delete secret tls-ca
```
Then restart the Rancher pods:
```bash
kubectl -n cattle-system rollout restart deploy/rancher
```
### Issue: Stuck Cluster Imports
If a cluster gets stuck during import, clean up the agent resources and try again:
```bash
kubectl delete clusterrole cattle-admin cluster-owner
kubectl delete clusterrolebinding cattle-admin-binding cluster-owner
kubectl delete namespace cattle-system
```
## The Future of Rancher
With SUSE's acquisition of Rancher Labs, the future looks bright. The latest Rancher updates have added:
- Better integration with cloud providers
- Improved security features
- Enhanced multi-cluster management
- Lower resource requirements (great for home labs)
My wish list for future versions includes:
- Native GitOps for everything (not just workloads)
- Better templating for one-click deployments
- More pre-configured monitoring dashboards
## Wrapping Up
Rancher has transformed how I manage my home Kubernetes clusters. What used to be a complex, time-consuming task is now almost... fun? If you're running Kubernetes at home and haven't tried Rancher yet, you're missing out on one of the best tools in the Kubernetes ecosystem.
Sure, you could manage everything with kubectl and YAML files (and I still do that sometimes), but having a well-designed UI for management, monitoring, and troubleshooting saves countless hours and reduces the learning curve for those just getting started with Kubernetes.
Are you using Rancher or another tool to manage your Kubernetes clusters? What's been your experience? Let me know in the comments!
---
_This post was last updated on March 5, 2024 with information about Rancher v2.7 features and Fleet GitOps capabilities._

View File

@ -1,40 +0,0 @@
---
title: Starting My Digital Garden
pubDate: 2025-04-19
tags:
- blog
- meta
- digital-garden
draft: false
heroImage: /blog/images/posts/prometheusk8.png
---
# Starting My Digital Garden
## Introduction
Today I'm launching my public digital garden - a space where I'll share my thoughts, ideas, and projects with the world.
## What to Expect
This site contains two main sections:
1. **Journal** - Daily notes and thoughts, more raw and in-the-moment
2. **Blog** - More structured and polished articles on various topics
I'll be using my daily journal to capture ideas as they happen, and some of these will evolve into more detailed blog posts over time.
## Why a Digital Garden?
Unlike traditional blogs that are often published, then forgotten, a digital garden is meant to grow and evolve over time. I'll be revisiting and updating content as my thoughts and understanding develop.
## Topics I'll Cover
- Technology projects I'm working on
- Learning notes and discoveries
- Workflow and productivity systems
- Occasional personal reflections
## Stay Connected
Feel free to check back regularly to see what's growing in this garden. The journal section will be updated most frequently, while blog posts will appear when ideas have had time to mature.

View File

@ -1,14 +0,0 @@
---
title: Test Post
pubDate: 2024-03-20
description: This is a test post to verify the blog setup
category: Test
tags:
- test
draft: true
heroImage: /blog/images/posts/prometheusk8.png
---
# Test Post
This is a test post to verify that the blog setup is working correctly.

View File

@ -1,517 +0,0 @@
---
title: Setting Up VS Code Server for Remote Development Anywhere
description: How to set up and configure VS Code Server for seamless remote development from any device
pubDate: 2023-04-18
updatedDate: 2024-04-19
category: Services
tags:
- vscode
- remote-development
- self-hosted
- coding
- homelab
heroImage: /blog/images/posts/prometheusk8.png
---
If you're like me, you probably find yourself coding on multiple devices - maybe a desktop at home, a laptop when traveling, or even occasionally on a tablet. For years, keeping development environments in sync was a pain point. Enter [VS Code Server](https://code.visualstudio.com/docs/remote/vscode-server), the solution that has completely transformed my development workflow.
Today, I want to show you how to set up your own self-hosted VS Code Server that lets you code from literally any device with a web browser, all while using your powerful home server for the heavy lifting.
## Why VS Code Server?
Before we dive into the setup, let's talk about why you might want this:
- **Consistent environment**: The same development setup, extensions, and configurations regardless of which device you're using.
- **Resource optimization**: Run resource-intensive tasks (builds, tests) on your powerful server instead of your laptop.
- **Work from anywhere**: Access your development environment from any device with a browser, even an iPad or a borrowed computer.
- **Seamless switching**: Start working on one device and continue on another without missing a beat.
I've been using this setup for months now, and it's been a game-changer for my productivity. Let's get into the setup!
## Setting Up VS Code Server
There are a few ways to run VS Code Server. I'll cover the official method and my preferred Docker approach.
### Option 1: Official CLI Installation
The VS Code team provides a CLI for setting up the server:
```bash
# Download and install the CLI
curl -fsSL https://code.visualstudio.com/sha/download?build=stable&os=cli-alpine-x64 -o vscode_cli.tar.gz
tar -xf vscode_cli.tar.gz
sudo mv code /usr/local/bin/
# Start the server
code serve-web --accept-server-license-terms --host 0.0.0.0
```
This method is straightforward but requires you to manage the process yourself.
### Option 2: Docker Installation (My Preference)
I prefer using Docker for easier updates and management. Here's my `docker-compose.yml`:
```yaml
version: '3'
services:
code-server:
image: linuxserver/code-server:latest
container_name: code-server
environment:
- PUID=1000
- PGID=1000
- TZ=America/Los_Angeles
- PASSWORD=your_secure_password # Consider using Docker secrets instead
- SUDO_PASSWORD=your_sudo_password # Optional
- PROXY_DOMAIN=code.yourdomain.com # Optional
volumes:
- ./config:/config
- /path/to/your/projects:/projects
- /path/to/your/home:/home/coder
ports:
- 8443:8443
restart: unless-stopped
```
Run it with:
```bash
docker-compose up -d
```
Your VS Code Server will be available at `https://your-server-ip:8443`.
### Option 3: Kubernetes Deployment
For those running Kubernetes (like me), here's a YAML manifest:
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: vscode-server
namespace: development
spec:
replicas: 1
selector:
matchLabels:
app: vscode-server
template:
metadata:
labels:
app: vscode-server
spec:
containers:
- name: vscode-server
image: linuxserver/code-server:latest
env:
- name: PUID
value: "1000"
- name: PGID
value: "1000"
- name: TZ
value: "America/Los_Angeles"
- name: PASSWORD
valueFrom:
secretKeyRef:
name: vscode-secrets
key: password
ports:
- containerPort: 8443
volumeMounts:
- name: config
mountPath: /config
- name: projects
mountPath: /projects
volumes:
- name: config
persistentVolumeClaim:
claimName: vscode-config
- name: projects
persistentVolumeClaim:
claimName: vscode-projects
---
apiVersion: v1
kind: Service
metadata:
name: vscode-server
namespace: development
spec:
selector:
app: vscode-server
ports:
- port: 8443
targetPort: 8443
type: ClusterIP
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: vscode-server
namespace: development
annotations:
kubernetes.io/ingress.class: nginx
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- code.yourdomain.com
secretName: vscode-tls
rules:
- host: code.yourdomain.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: vscode-server
port:
number: 8443
```
## Accessing VS Code Server Securely
You don't want to expose your development environment directly to the internet without proper security. Here are my recommendations:
### 1. Use a Reverse Proxy with SSL
I use Traefik as a reverse proxy with automatic SSL certificate generation:
```yaml
# traefik.yml dynamic config
http:
routers:
vscode:
rule: "Host(`code.yourdomain.com`)"
service: "vscode"
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
vscode:
loadBalancer:
servers:
- url: "http://localhost:8443"
```
### 2. Set Up Authentication
The LinuxServer image already includes basic authentication, but you can add another layer with something like Authelia:
```yaml
# authelia configuration.yml
access_control:
default_policy: deny
rules:
- domain: code.yourdomain.com
policy: two_factor
subject: "group:developers"
```
### 3. Use Cloudflare Tunnel
For ultimate security, I use a Cloudflare Tunnel to avoid exposing any ports:
```yaml
# cloudflared config.yml
tunnel: your-tunnel-id
credentials-file: /etc/cloudflared/creds.json
ingress:
- hostname: code.yourdomain.com
service: http://localhost:8443
originRequest:
noTLSVerify: true
- service: http_status:404
```
## Configuring Your VS Code Server Environment
Once your server is running, it's time to set it up for optimal productivity:
### 1. Install Essential Extensions
Here are the must-have extensions I install first:
```bash
# From the VS Code terminal
code-server --install-extension ms-python.python
code-server --install-extension ms-azuretools.vscode-docker
code-server --install-extension dbaeumer.vscode-eslint
code-server --install-extension esbenp.prettier-vscode
code-server --install-extension github.copilot
code-server --install-extension golang.go
```
Or you can install them through the Extensions marketplace in the UI.
### 2. Configure Settings Sync
To keep your settings in sync between instances:
1. Open the Command Palette (Ctrl+Shift+P)
2. Search for "Settings Sync: Turn On"
3. Sign in with your GitHub or Microsoft account
### 3. Set Up Git Authentication
For seamless Git operations:
```bash
# Generate a new SSH key if needed
ssh-keygen -t ed25519 -C "your_email@example.com"
# Add to your GitHub/GitLab account
cat ~/.ssh/id_ed25519.pub
# Configure Git
git config --global user.name "Your Name"
git config --global user.email "your_email@example.com"
```
## Power User Features
Now let's look at some advanced configurations that make VS Code Server even more powerful:
### 1. Workspace Launcher
I created a simple HTML page that lists all my projects for quick access:
```html
<!DOCTYPE html>
<html>
<head>
<title>LaForceIT Workspace Launcher</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #1e1e1e;
color: #d4d4d4;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.project-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 15px;
margin-top: 20px;
}
.project-card {
background-color: #252526;
border-radius: 5px;
padding: 15px;
transition: transform 0.2s;
}
.project-card:hover {
transform: translateY(-5px);
background-color: #2d2d2d;
}
a {
color: #569cd6;
text-decoration: none;
}
h1 { color: #569cd6; }
h2 { color: #4ec9b0; }
</style>
</head>
<body>
<h1>LaForceIT Workspace Launcher</h1>
<div class="project-list">
<div class="project-card">
<h2>ArgoBox</h2>
<p>My Kubernetes home lab platform</p>
<a href="/projects/argobox">Open Workspace</a>
</div>
<div class="project-card">
<h2>Blog</h2>
<p>LaForceIT blog and digital garden</p>
<a href="/projects/blog">Open Workspace</a>
</div>
<!-- Add more projects as needed -->
</div>
</body>
</html>
```
### 2. Custom Terminal Profile
Add this to your `settings.json` for a better terminal experience:
```json
"terminal.integrated.profiles.linux": {
"bash": {
"path": "bash",
"icon": "terminal-bash",
"args": ["-l"]
},
"zsh": {
"path": "zsh"
},
"customProfile": {
"path": "bash",
"args": ["-c", "neofetch && bash -l"],
"icon": "terminal-bash",
"overrideName": true
}
},
"terminal.integrated.defaultProfile.linux": "customProfile"
```
### 3. Persistent Development Containers
I use Docker-in-Docker to enable VS Code Dev Containers:
```yaml
version: '3'
services:
code-server:
# ... other config from above
volumes:
# ... other volumes
- /var/run/docker.sock:/var/run/docker.sock
environment:
# ... other env vars
- DOCKER_HOST=unix:///var/run/docker.sock
```
## Real-World Examples: How I Use VS Code Server
Let me share a few real-world examples of how I use this setup:
### Example 1: Coding on iPad During Travel
When traveling with just my iPad, I connect to my VS Code Server to work on my projects. With a Bluetooth keyboard and the amazing iPad screen, it's a surprisingly good experience. The heavy compilation happens on my server back home, so battery life on the iPad remains excellent.
### Example 2: Pair Programming Sessions
When helping friends debug issues, I can generate a temporary access link to my VS Code Server:
```bash
# Create a time-limited token
TEMP_TOKEN=$(openssl rand -hex 16)
echo "Token: $TEMP_TOKEN" > /tmp/temp_token
curl -X POST -H "Content-Type: application/json" \
-d "{\"token\":\"$TEMP_TOKEN\", \"expiresIn\":\"2h\"}" \
http://localhost:8443/api/auth/temporary
```
### Example 3: Switching Between Devices
I often start coding on my desktop, then switch to my laptop when moving to another room. With VS Code Server, I just close the browser on one device and open it on another - my entire session, including unsaved changes and terminal state, remains intact.
## Monitoring and Maintaining Your VS Code Server
To keep your server running smoothly:
### 1. Set Up Health Checks
I use Uptime Kuma to monitor my VS Code Server:
```yaml
# Docker Compose snippet for Uptime Kuma
uptime-kuma:
image: louislam/uptime-kuma:latest
container_name: uptime-kuma
volumes:
- ./uptime-kuma:/app/data
ports:
- 3001:3001
restart: unless-stopped
```
Add a monitor that checks `https://code.yourdomain.com/healthz` endpoint.
### 2. Regular Backups
Set up a cron job to back up your VS Code Server configuration:
```bash
# /etc/cron.daily/backup-vscode-server
#!/bin/bash
tar -czf /backups/vscode-server-$(date +%Y%m%d).tar.gz /path/to/config
```
### 3. Update Script
Create a script for easy updates:
```bash
#!/bin/bash
# update-vscode-server.sh
cd /path/to/docker-compose
docker-compose pull
docker-compose up -d
docker system prune -f
```
## Troubleshooting Common Issues
Here are solutions to some issues I've encountered:
### Issue: Extensions Not Installing
If extensions fail to install, try:
```bash
# Clear the extensions cache
rm -rf ~/.vscode-server/extensions/*
# Restart the server
docker-compose restart code-server
```
### Issue: Performance Problems
If you're experiencing lag:
```bash
# Adjust memory limits in docker-compose.yml
services:
code-server:
# ... other config
deploy:
resources:
limits:
memory: 4G
reservations:
memory: 1G
```
### Issue: Git Integration Not Working
For Git authentication issues:
```bash
# Make sure your SSH key is properly set up
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519
# Test your connection
ssh -T git@github.com
```
## Why I Prefer Self-Hosted Over GitHub Codespaces
People often ask why I don't just use GitHub Codespaces. Here's my take:
1. **Cost**: Self-hosted is essentially free (if you already have a server).
2. **Privacy**: All my code remains on my hardware.
3. **Customization**: Complete control over the environment.
4. **Performance**: My server has 64GB RAM and a 12-core CPU - better than most cloud options.
5. **Availability**: Works even when GitHub is down or when I have limited internet.
## Wrapping Up
VS Code Server has truly changed how I approach development. The ability to have a consistent, powerful environment available from any device has increased my productivity and eliminated the friction of context-switching between machines.
Whether you're a solo developer or part of a team, having a centralized development environment that's accessible from anywhere is incredibly powerful. And the best part? It uses the familiar VS Code interface that millions of developers already know and love.
Have you tried VS Code Server or similar remote development solutions? I'd love to hear about your setup and experiences in the comments!
---
_This post was last updated on March 10, 2024 with information about the latest VS Code Server features and Docker image updates._

9
public/favicon.svg Normal file
View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

View File

View File

@ -0,0 +1 @@
data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAEAAAAAAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCABAAIADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9/KKKKACiiigAooooAKKKKACiiigAorH8Y+MdH8DaRLqWt6nY6PpluAZ7y/uEt7eEE4G+R2CqM9MkDNfOPx3/AOCt/wAFvhPdXFnJ4ys/EGoxlkjs/DsL37sR6soWEeu6ZW9qAPpeivgHXf8AgrT4nv8AxDLpujfCS61HTwcJfS6zNazjP8bQLZTBlHbcN3WvMPGH/BZ3xlcSyx+Gvh5pVn5RKrJq2oT3HL9SsQtgV4+6d2e9AH6F/Gj44eFvgJ4JuPEXizV7fRdIhkSFZpAzO8jsAFREBZ2JIAAAJJHv18x+F/8AgqX8KfFfiGz0uPU9Us7q9lEMU2oaFe2lsGPTfM8YjQY7yMoycZAJFfnd8U/jl8Qv2hdQ87xV4q1XW7GcmS1tJ5/KsR/dhgTCKRnoQDgc5ry7WNXS2UbGywUFztyAT3Jzzn+lAH9H+k6pa61p8N5ZzxXlncIJIpreQSRyKRkFWUkMD6EVPXwD/wAEh/jnqOrXGq+AtWupL6yt7dNR02aViXgQuskiAnkDcHwPRwOmK++aACiiigAooooAKKKKACvI/wBpz9rHwX+zD4ca91+9aXULiPfZaRZESX14OwRCRtTIBLsFUZ6kkA+uV+bP7UF/N+07+0lqlxbyFtKhuJIYPlyqWsSny/woGwvGdzkscmgDpvit/wAFNPiB8TI5YvDceneELA/LHLBCLrUZPRvNf5ExzjbHGcfxGvnXWdWvfEGotdX93cX93KAHmuZmllfHbcxyeB7/AErOTQY7eXft+Vdwww5BIwOB0B7jPervh7Ql1jWvJkb5TncVyDsHXA7ZwQCeOv0oA5/UpRGRFEGxyNmcDjqT9O30p9jpdxeXCxwRySO/AVBn8/T/APXXvNn8P9NigZYdOhMgwySPl2HHJDMc5+o49sVoQ+HbaJVX7PCoXJGIgMnBGeD3z+VSpsrlOCPL/D3wuuLaaNZreSSRyNwVSFX1z7fXB7ZzXrfhvwFc2JWTy2ji6KWUgj6Ajg+x59K6bRbGKLBjWP5ecCIHnrz6Y/l7V0Vrb71VchvU46Y9+MdfpUOpJkxopHOaH4eElu0Yjhj3LjBQY9D06H37fTnlv2atZn+B37Wfgm9h3Nb6hew6fOoOcxzkRZBx1BkTOMdO9eo6FY7djtAyY+YbCQwGexGeORkVyPx48Iv4q8H2ur20bS32hyi5TZ96SLOJFx6qu8gdtoHvVRlcbR+p9FYvgDxnb+O/A+ja9a7/ACNWsYL6LcMNtkjWRcj0O4VtVRIUUUUAFFFFABX5bfteeEvEXgf426ja6kZLiO6aWS3vQMLdRsSGDD+E4IKnqOCMMpx+pNcf8dPhLY/Gj4Y6toV1tSa4jL2tw6bvs9yo3RyAdSAwww/iVmGRmgD8sdN0+bUJ1WFZHkY5VQM7vbA6mvZPBHw7ijsnmvIlkknXeI+uF4HUdMAfzruPh7+zlNoWrTR3Fq9vcK5ErBgJOP73HzDHbjHSvXvDvwUW/wBMWTaMYBEecHdng5PQ+nH0PeoY0eU+BvBNxJcRlo9ix4AVQFwPZQAMDPsK9o8OeE4okUNGFVRnkYb6evTHPtxXc+GPg3b2e3zE24xnYpBx3x9Pp0rd1HwhHZWrNGvzAZHG7PoMetJ6DR50mi/ZmZnbyyyhdoUqGI6dOh6/h+VEulP5O7btZt2OcgY4HH4V0d5pTWzKdo27iMhhnkY6dCe351mzxKDjaoTg85wMdTk9KmwGfJ5i4X5V2nI6jH8umOD7da4H4y6QniD4da1CNu5baScZGQXtw0y/+QxXpl1aGRTtKsVGMg//AFqqmCOaIwyIsse4hlPVXQncD7gqQfqKpCZ5t+xh8SJvFnwjl0e6kaS88O3BtgW+9JbSfPC3pxlx9EFe111P7K/gC5+H3we+yx/ubrUWN9dJn/VNJgJGf9pI0jU+6muWrbkCiiikAUUUUAFFFFAHhH7YHwcj8XeHj4ksYFGraSgNwUXDXNuDg5x1KElfoWxnbzg/hF8fLjwzeR6fdzNNBOyqyP8Ae4Gc47HtnPQD3I/TuvlD9qH9lddS1abxJocYjulYzXltENqSrzukT0cZJI7gkHndjzsNbldzow83J8sj3P4ZfG601nTI8OskbKu5JBuVh3B9P0r1fRfiCt9b7lLbh1z1H+fw+or8xNC8Wa18MNbhmtpJFUOA8bNmOVScgg/hzX0N4G+OcN1bxs0jNIADJG5wy5HX+h9wazlTa2LVSMtGfV9/rkdy5kb5pOcZ/wD1d6pXl4u0/MWYdeDj3//XxXkWh/F+3vYY2jm3Y6g88Ht9e3QdK3o/Ho8tflYL94/Nz/n86i7NIxdrt2OrvAJFba3yqN2DnJ96yri5aGRlAZcfMNowSep9x+FZMvjpYtzbvl6q24kevHrWfeeNY4wrNIHyecHAHua0i2c1SSN9tTCx8MCw3dMEHsR7dv8AGsa2mmS7/dv5cyK2CF2hieOQM8/j0/Osm68Yx6hKy75G29H/AMMj86h0vxR9mvGlhbejR7cE5yOnPseK1S1Mmz7Q+GPhweFfAmkabuWR9PsobdpE+7IY41Usp9CQSPaugoooNCkFFFFAgooooAKKKKACiiigDH8VeB9H8Z2n2fVtNs9St/4UuoFk2H1U4O0+6kGvHfFf7Cuhak7zaVqF3pMjZIiY/aYc9wA21lHsWb/dHavdqKAPjjxp+x94i8P3DfYbCS+iVsbI3EM49MJJgMfZWPGBk15Z4k+CniSNW3aHdSRgFnEUfmKwxwAEJAGMnjAwRntX6HZGM1BdWUV9D5c0SSxtw0cihlYehB60XYrI/OG/8Iahp0zeY11as+Su+Jo2HPbcAD07E844qZLW4W2aSaG4dOSRsdmJ+gBP5V9++IfhH4X8SCSOS0FlNztuLQiNh7nGFYehBrx3xT+x7d2Lc2kbr03QyhkPqdk21hjtljx6UlMd0fJQsZbyRWjjboc71O32HHOM/StLTdJlK42yMVOflGcH07/5+le06n+zdf2J33VtuUZxujUOvuI3UuPcHGf4cGsPUPg/HZYZ9PvLbPXzIfPGPUSL+6/HK5q+cxdI2v2XfDUmufEizkk3C10+N7qZs4BIIWNQO/ztuPspr7Rqj4V8K2fgzw7Z6TYo0dpaJ5a7xhm5JLMe5JJJPua0BRYQtQooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFR3FvHcxNHLGssbjDI6hlYeoI6iiigCrL4X06b79haP8A71sivt61Avhfw9b/APHhpVqnH/LHYmPyGKKKaXYTbJYYEgXbGiRr2VFCgfgBUlFFMQUUUUAFFFFABRRRQAUUUUAf/9k=

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -0,0 +1 @@
data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAEAAAAAAAD/2wBDAAIBAQIBAQICAgICAgICAwUDAwMDAwYEBAMFBwYHBwcGBwcICQsJCAgKCAcHCg0KCgsMDAwMBwkODw0MDgsMDAz/2wBDAQICAgMDAwYDAwYMCAcIDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAz/wAARCABAAIADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD9/KKKKACiiigAooooAKKKKACiiigAorH8Y+MdH8DaRLqWt6nY6PpluAZ7y/uEt7eEE4G+R2CqM9MkDNfOPx3/AOCt/wAFvhPdXFnJ4ys/EGoxlkjs/DsL37sR6soWEeu6ZW9qAPpeivgHXf8AgrT4nv8AxDLpujfCS61HTwcJfS6zNazjP8bQLZTBlHbcN3WvMPGH/BZ3xlcSyx+Gvh5pVn5RKrJq2oT3HL9SsQtgV4+6d2e9AH6F/Gj44eFvgJ4JuPEXizV7fRdIhkSFZpAzO8jsAFREBZ2JIAAAJJHv18x+F/8AgqX8KfFfiGz0uPU9Us7q9lEMU2oaFe2lsGPTfM8YjQY7yMoycZAJFfnd8U/jl8Qv2hdQ87xV4q1XW7GcmS1tJ5/KsR/dhgTCKRnoQDgc5ry7WNXS2UbGywUFztyAT3Jzzn+lAH9H+k6pa61p8N5ZzxXlncIJIpreQSRyKRkFWUkMD6EVPXwD/wAEh/jnqOrXGq+AtWupL6yt7dNR02aViXgQuskiAnkDcHwPRwOmK++aACiiigAooooAKKKKACvI/wBpz9rHwX+zD4ca91+9aXULiPfZaRZESX14OwRCRtTIBLsFUZ6kkA+uV+bP7UF/N+07+0lqlxbyFtKhuJIYPlyqWsSny/woGwvGdzkscmgDpvit/wAFNPiB8TI5YvDceneELA/LHLBCLrUZPRvNf5ExzjbHGcfxGvnXWdWvfEGotdX93cX93KAHmuZmllfHbcxyeB7/AErOTQY7eXft+Vdwww5BIwOB0B7jPervh7Ql1jWvJkb5TncVyDsHXA7ZwQCeOv0oA5/UpRGRFEGxyNmcDjqT9O30p9jpdxeXCxwRySO/AVBn8/T/APXXvNn8P9NigZYdOhMgwySPl2HHJDMc5+o49sVoQ+HbaJVX7PCoXJGIgMnBGeD3z+VSpsrlOCPL/D3wuuLaaNZreSSRyNwVSFX1z7fXB7ZzXrfhvwFc2JWTy2ji6KWUgj6Ajg+x59K6bRbGKLBjWP5ecCIHnrz6Y/l7V0Vrb71VchvU46Y9+MdfpUOpJkxopHOaH4eElu0Yjhj3LjBQY9D06H37fTnlv2atZn+B37Wfgm9h3Nb6hew6fOoOcxzkRZBx1BkTOMdO9eo6FY7djtAyY+YbCQwGexGeORkVyPx48Iv4q8H2ur20bS32hyi5TZ96SLOJFx6qu8gdtoHvVRlcbR+p9FYvgDxnb+O/A+ja9a7/ACNWsYL6LcMNtkjWRcj0O4VtVRIUUUUAFFFFABX5bfteeEvEXgf426ja6kZLiO6aWS3vQMLdRsSGDD+E4IKnqOCMMpx+pNcf8dPhLY/Gj4Y6toV1tSa4jL2tw6bvs9yo3RyAdSAwww/iVmGRmgD8sdN0+bUJ1WFZHkY5VQM7vbA6mvZPBHw7ijsnmvIlkknXeI+uF4HUdMAfzruPh7+zlNoWrTR3Fq9vcK5ErBgJOP73HzDHbjHSvXvDvwUW/wBMWTaMYBEecHdng5PQ+nH0PeoY0eU+BvBNxJcRlo9ix4AVQFwPZQAMDPsK9o8OeE4okUNGFVRnkYb6evTHPtxXc+GPg3b2e3zE24xnYpBx3x9Pp0rd1HwhHZWrNGvzAZHG7PoMetJ6DR50mi/ZmZnbyyyhdoUqGI6dOh6/h+VEulP5O7btZt2OcgY4HH4V0d5pTWzKdo27iMhhnkY6dCe351mzxKDjaoTg85wMdTk9KmwGfJ5i4X5V2nI6jH8umOD7da4H4y6QniD4da1CNu5baScZGQXtw0y/+QxXpl1aGRTtKsVGMg//AFqqmCOaIwyIsse4hlPVXQncD7gqQfqKpCZ5t+xh8SJvFnwjl0e6kaS88O3BtgW+9JbSfPC3pxlx9EFe111P7K/gC5+H3we+yx/ubrUWN9dJn/VNJgJGf9pI0jU+6muWrbkCiiikAUUUUAFFFFAHhH7YHwcj8XeHj4ksYFGraSgNwUXDXNuDg5x1KElfoWxnbzg/hF8fLjwzeR6fdzNNBOyqyP8Ae4Gc47HtnPQD3I/TuvlD9qH9lddS1abxJocYjulYzXltENqSrzukT0cZJI7gkHndjzsNbldzow83J8sj3P4ZfG601nTI8OskbKu5JBuVh3B9P0r1fRfiCt9b7lLbh1z1H+fw+or8xNC8Wa18MNbhmtpJFUOA8bNmOVScgg/hzX0N4G+OcN1bxs0jNIADJG5wy5HX+h9wazlTa2LVSMtGfV9/rkdy5kb5pOcZ/wD1d6pXl4u0/MWYdeDj3//XxXkWh/F+3vYY2jm3Y6g88Ht9e3QdK3o/Ho8tflYL94/Nz/n86i7NIxdrt2OrvAJFba3yqN2DnJ96yri5aGRlAZcfMNowSep9x+FZMvjpYtzbvl6q24kevHrWfeeNY4wrNIHyecHAHua0i2c1SSN9tTCx8MCw3dMEHsR7dv8AGsa2mmS7/dv5cyK2CF2hieOQM8/j0/Osm68Yx6hKy75G29H/AMMj86h0vxR9mvGlhbejR7cE5yOnPseK1S1Mmz7Q+GPhweFfAmkabuWR9PsobdpE+7IY41Usp9CQSPaugoooNCkFFFFAgooooAKKKKACiiigDH8VeB9H8Z2n2fVtNs9St/4UuoFk2H1U4O0+6kGvHfFf7Cuhak7zaVqF3pMjZIiY/aYc9wA21lHsWb/dHavdqKAPjjxp+x94i8P3DfYbCS+iVsbI3EM49MJJgMfZWPGBk15Z4k+CniSNW3aHdSRgFnEUfmKwxwAEJAGMnjAwRntX6HZGM1BdWUV9D5c0SSxtw0cihlYehB60XYrI/OG/8Iahp0zeY11as+Su+Jo2HPbcAD07E844qZLW4W2aSaG4dOSRsdmJ+gBP5V9++IfhH4X8SCSOS0FlNztuLQiNh7nGFYehBrx3xT+x7d2Lc2kbr03QyhkPqdk21hjtljx6UlMd0fJQsZbyRWjjboc71O32HHOM/StLTdJlK42yMVOflGcH07/5+le06n+zdf2J33VtuUZxujUOvuI3UuPcHGf4cGsPUPg/HZYZ9PvLbPXzIfPGPUSL+6/HK5q+cxdI2v2XfDUmufEizkk3C10+N7qZs4BIIWNQO/ztuPspr7Rqj4V8K2fgzw7Z6TYo0dpaJ5a7xhm5JLMe5JJJPua0BRYQtQooopgFFFFABRRRQAUUUUAFFFFABRRRQAUUUUAFR3FvHcxNHLGssbjDI6hlYeoI6iiigCrL4X06b79haP8A71sivt61Avhfw9b/APHhpVqnH/LHYmPyGKKKaXYTbJYYEgXbGiRr2VFCgfgBUlFFMQUUUUAFFFFABRRRQAUUUUAf/9k=

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="1200" height="600" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#050a18;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0d1529;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="1200" height="600" fill="url(#grad1)"/>
<text x="600" y="250" font-family="Arial" font-size="50" fill="#e2e8f0" text-anchor="middle">K3s Kubernetes</text>
<text x="600" y="320" font-family="Arial" font-size="30" fill="#3b82f6" text-anchor="middle">Lightweight Kubernetes for Edge and IoT</text>
<g transform="translate(550,150) scale(0.6)">
<path d="M 50,50 L 150,50 L 150,150 L 50,150 Z" fill="none" stroke="#06b6d4" stroke-width="10"/>
<path d="M 75,75 L 175,75 L 175,175 L 75,175 Z" fill="none" stroke="#3b82f6" stroke-width="10"/>
<path d="M 100,100 L 200,100 L 200,200 L 100,200 Z" fill="none" stroke="#8b5cf6" stroke-width="10"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

0
scripts/fetch-images.js Normal file
View File

126
scripts/init-blog-repo.sh Normal file
View File

@ -0,0 +1,126 @@
#!/bin/bash
# Initialize Blog Repository Setup
echo "Initializing blog repository setup..."
# Set up Git configuration
git config core.symlinks true
# Create necessary directories
mkdir -p src/content public/blog scripts .git/hooks
# Create symbolic links for src/content
echo "Creating symbolic links for src/content..."
ln -sf /mnt/synology/obsidian/Public/Blog/posts src/content/posts
ln -sf /mnt/synology/obsidian/Public/Blog/projects src/content/projects
ln -sf /mnt/synology/obsidian/Public/Blog/configurations src/content/configurations
ln -sf /mnt/synology/obsidian/Public/Blog/external-posts src/content/external-posts
# Create symbolic links for public/blog
echo "Creating symbolic links for public/blog..."
ln -sf /mnt/synology/obsidian/Public/Blog/configs public/blog/configs
ln -sf /mnt/synology/obsidian/Public/Blog/images public/blog/images
ln -sf /mnt/synology/obsidian/Public/Blog/infrastructure public/blog/infrastructure
ln -sf /mnt/synology/obsidian/Public/Blog/posts public/blog/posts
# Create the pre-commit hook
cat > .git/hooks/pre-commit << 'EOF'
#!/bin/bash
# Pre-commit hook to process symbolic links
echo "Running pre-commit hook for blog content..."
# Get the absolute path to the script
SCRIPT_PATH="$(git rev-parse --show-toplevel)/scripts/process-content-links.sh"
# Check if the script exists and is executable
if [ -x "$SCRIPT_PATH" ]; then
bash "$SCRIPT_PATH"
# Add any new or changed files resulting from the script
git add -A
else
echo "Error: Content processing script not found or not executable at $SCRIPT_PATH"
echo "Please ensure the script exists and has execute permissions"
exit 1
fi
EOF
# Create the post-commit hook
cat > .git/hooks/post-commit << 'EOF'
#!/bin/bash
# Post-commit hook to restore symbolic links
echo "Running post-commit hook to restore symbolic links..."
# Array of content directories and their targets
declare -A SYMLINK_TARGETS=(
["src/content/posts"]="/mnt/synology/obsidian/Public/Blog/posts"
["src/content/projects"]="/mnt/synology/obsidian/Public/Blog/projects"
["src/content/configurations"]="/mnt/synology/obsidian/Public/Blog/configurations"
["src/content/external-posts"]="/mnt/synology/obsidian/Public/Blog/external-posts"
["public/blog/configs"]="/mnt/synology/obsidian/Public/Blog/configs"
["public/blog/images"]="/mnt/synology/obsidian/Public/Blog/images"
["public/blog/infrastructure"]="/mnt/synology/obsidian/Public/Blog/infrastructure"
["public/blog/posts"]="/mnt/synology/obsidian/Public/Blog/posts"
)
for dir_path in "${!SYMLINK_TARGETS[@]}"; do
target="${SYMLINK_TARGETS[$dir_path]}"
if [ -d "$target" ]; then
echo "Restoring symlink for $dir_path -> $target"
rm -rf "$dir_path"
mkdir -p "$(dirname "$dir_path")"
ln -s "$target" "$dir_path"
else
echo "Warning: Target directory $target does not exist"
fi
done
echo "Symbolic links restored!"
EOF
# Make hooks executable
chmod +x .git/hooks/pre-commit .git/hooks/post-commit
# Create process-content-links.sh
cat > scripts/process-content-links.sh << 'EOF'
#!/bin/bash
# Script to handle symbolic links before commit
echo "Processing symbolic links for content..."
# Array of content directories to process
declare -A CONTENT_PATHS
# src/content directories
CONTENT_PATHS["posts"]="src/content/posts"
CONTENT_PATHS["projects"]="src/content/projects"
CONTENT_PATHS["configurations"]="src/content/configurations"
CONTENT_PATHS["external-posts"]="src/content/external-posts"
# public/blog directories
CONTENT_PATHS["configs"]="public/blog/configs"
CONTENT_PATHS["images"]="public/blog/images"
CONTENT_PATHS["infrastructure"]="public/blog/infrastructure"
CONTENT_PATHS["blog-posts"]="public/blog/posts"
for dir_name in "${!CONTENT_PATHS[@]}"; do
dir_path="${CONTENT_PATHS[$dir_name]}"
if [ -L "$dir_path" ]; then
echo "Processing $dir_path..."
target=$(readlink "$dir_path")
rm "$dir_path"
mkdir -p "$(dirname "$dir_path")"
cp -r "$target" "$dir_path"
git add "$dir_path"
echo "Processed $dir_path -> $target"
else
echo "Skipping $dir_path (not a symbolic link)"
fi
done
echo "Content processing complete!"
EOF
# Make process-content-links.sh executable
chmod +x scripts/process-content-links.sh
echo "Setup complete! The repository is now configured for automatic symbolic link handling."

76
scripts/process-content-links.sh Executable file → Normal file
View File

@ -1,28 +1,58 @@
#!/bin/bash #!/bin/bash
# Script to handle symbolic links before commit
echo "Processing symbolic links for content..."
declare -A CONTENT_PATHS # Configuration
# src/content directories BLOG_CONTENT_PATH="src/content"
CONTENT_PATHS["posts"]="src/content/posts" ASSETS_PATH="public/assets"
CONTENT_PATHS["projects"]="src/content/projects" BLOG_SOURCE="src/content/blog"
CONTENT_PATHS["configurations"]="src/content/configurations"
CONTENT_PATHS["external-posts"]="src/content/external-posts"
# public/blog directories
CONTENT_PATHS["configs"]="public/blog/configs"
CONTENT_PATHS["images"]="public/blog/images"
CONTENT_PATHS["infrastructure"]="public/blog/infrastructure"
CONTENT_PATHS["blog-posts"]="public/blog/posts"
for dir_name in "${!CONTENT_PATHS[@]}"; do # Create necessary directories if they don't exist
dir_path="${CONTENT_PATHS[$dir_name]}" mkdir -p "$BLOG_CONTENT_PATH/posts"
if [ -L "$dir_path" ]; then mkdir -p "$BLOG_CONTENT_PATH/configurations"
echo "Processing $dir_path..." mkdir -p "$BLOG_CONTENT_PATH/projects"
target=$(readlink "$dir_path") mkdir -p "$ASSETS_PATH/images"
rm "$dir_path"
mkdir -p "$(dirname "$dir_path")" # Function to process markdown files
cp -r "$target" "$dir_path" process_markdown() {
git add "$dir_path" local file="$1"
echo "Processed $dir_path -> $target" local filename=$(basename "$file")
# Convert Obsidian wiki-links to markdown links
sed -i 's/\[\[/[/g; s/\]\]/]/g' "$file"
# Update image paths
sed -i 's/\(!\[.*\](\)attachments\//\1\/assets\/images\//g' "$file"
echo "Processed $filename"
}
# Function to categorize content
categorize_content() {
local file="$1"
local filename=$(basename "$file")
# Read the frontmatter to determine the category
if grep -q "category: configuration" "$file"; then
cp "$file" "$BLOG_CONTENT_PATH/configurations/"
process_markdown "$BLOG_CONTENT_PATH/configurations/$filename"
elif grep -q "category: project" "$file"; then
cp "$file" "$BLOG_CONTENT_PATH/projects/"
process_markdown "$BLOG_CONTENT_PATH/projects/$filename"
else
cp "$file" "$BLOG_CONTENT_PATH/posts/"
process_markdown "$BLOG_CONTENT_PATH/posts/$filename"
fi
}
# Process all markdown files in the blog directory
for file in "$BLOG_SOURCE"/*.md; do
if [ -f "$file" ]; then
categorize_content "$file"
fi fi
done done
# Ensure images are in place
if [ -d "$BLOG_SOURCE/images" ]; then
cp -r "$BLOG_SOURCE/images/"* "$ASSETS_PATH/images/"
fi
echo "Content processing complete"

View File

@ -0,0 +1,372 @@
---
// 404.astro - Custom 404 error page
import BaseLayout from '../layouts/BaseLayout.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import Terminal from '../components/Terminal.astro';
// Terminal commands for the 404 page
const terminalCommands = [
{
prompt: "[system@argobox]$ ",
command: "find /var/www/laforceit -name \"requested-page\"",
output: ["find: No matches found"]
},
{
prompt: "[system@argobox]$ ",
command: "grep -r \"requested-page\" /var/www/laforceit",
output: ["No matches found in site content"]
},
{
prompt: "[system@argobox]$ ",
command: "echo $?",
output: ["1"]
},
{
prompt: "[system@argobox]$ ",
command: "suggest_pages",
output: [
"Running site diagnostics...",
"Suggested pages:",
" - <a href='/'>Home</a>",
" - <a href='/blog'>Blog</a>",
" - <a href='/resources'>Resources</a>",
" - <a href='/projects'>Projects</a>"
]
},
{
prompt: "[system@argobox]$ ",
command: "return --code=404",
}
];
// Random tech error messages
const errorMessages = [
"Connection terminated unexpectedly.",
"Resource allocation failed: Page not found.",
"Route resolution error: Destination unreachable.",
"404: The requested URL does not exist on this server.",
"Network path not found: Check your request and try again."
];
// Get a random error message
const randomError = errorMessages[Math.floor(Math.random() * errorMessages.length)];
---
<BaseLayout title="404 - Page Not Found | LaForceIT" description="The requested page was not found on the LaForceIT tech blog.">
<Header slot="header" />
<main class="error-container">
<div class="error-content">
<div class="error-code">
<span class="error-digit">4</span>
<div class="error-digit-middle">
<div class="error-spinner"></div>
<span>0</span>
</div>
<span class="error-digit">4</span>
</div>
<h1 class="error-title">Page Not Found</h1>
<p class="error-message">{randomError}</p>
<div class="error-actions">
<a href="/" class="primary-button">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
<polyline points="9 22 9 12 15 12 15 22"></polyline>
</svg>
<span>Return Home</span>
</a>
<a href="/blog" class="secondary-button">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
<polyline points="15 3 21 3 21 9"></polyline>
<line x1="10" y1="14" x2="21" y2="3"></line>
</svg>
<span>Browse Articles</span>
</a>
</div>
</div>
<div class="error-terminal">
<Terminal commands={terminalCommands} title="system-error-trace" />
</div>
<div class="error-bg"></div>
<div class="glitch-effect"></div>
</main>
<Footer slot="footer" />
</BaseLayout>
<style>
.error-container {
min-height: 70vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
position: relative;
overflow: hidden;
}
.error-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle at 20% 35%, rgba(239, 68, 68, 0.05) 0%, transparent 50%),
radial-gradient(circle at 75% 15%, rgba(6, 182, 212, 0.05) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(139, 92, 246, 0.05) 0%, transparent 40%);
z-index: -1;
}
.error-bg::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(226, 232, 240, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(226, 232, 240, 0.03) 1px, transparent 1px);
background-size: 30px 30px;
}
.error-content {
max-width: 600px;
text-align: center;
margin-bottom: 3rem;
position: relative;
z-index: 1;
}
.error-code {
display: flex;
align-items: center;
justify-content: center;
font-size: 8rem;
font-weight: 800;
margin-bottom: 2rem;
line-height: 1;
gap: 1rem;
}
.error-digit {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-tertiary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 5px 15px rgba(6, 182, 212, 0.3);
}
.error-digit-middle {
position: relative;
width: 100px;
height: 130px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-secondary);
}
.error-spinner {
position: absolute;
width: 100%;
height: 100%;
border: 4px solid rgba(59, 130, 246, 0.2);
border-top: 4px solid var(--accent-secondary);
border-radius: 50%;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.error-title {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.error-message {
color: var(--text-secondary);
font-size: 1.1rem;
margin-bottom: 2rem;
font-family: var(--font-mono);
}
.error-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
}
.primary-button {
padding: 0.8rem 1.5rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
color: var(--bg-primary);
font-weight: 600;
border-radius: 8px;
border: none;
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.3);
}
.primary-button:hover {
transform: translateY(-3px);
box-shadow: 0 8px 20px rgba(6, 182, 212, 0.4);
}
.secondary-button {
padding: 0.8rem 1.5rem;
background: rgba(226, 232, 240, 0.1);
color: var(--text-primary);
font-weight: 600;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
transition: all 0.3s ease;
}
.secondary-button:hover {
background: rgba(226, 232, 240, 0.2);
border-color: rgba(255, 255, 255, 0.2);
}
.error-terminal {
width: 100%;
max-width: 700px;
margin: 0 auto;
}
.glitch-effect {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(45deg,
rgba(255, 0, 0, 0) 45%,
rgba(255, 0, 0, 0.03) 50%,
rgba(255, 0, 0, 0) 55%
);
background-size: 200% 200%;
pointer-events: none;
animation: glitch-scan 4s ease-in-out infinite;
z-index: 2;
mix-blend-mode: overlay;
}
@keyframes glitch-scan {
0%, 100% {
background-position: 0% 0%;
opacity: 0;
}
25%, 75% {
opacity: 0;
}
50% {
background-position: 100% 100%;
opacity: 1;
}
}
@media (max-width: 768px) {
.error-code {
font-size: 5rem;
}
.error-title {
font-size: 2rem;
}
.error-digit-middle {
width: 70px;
height: 90px;
}
.error-actions {
flex-direction: column;
}
}
</style>
<script>
// Add some randomized glitch effects to the 404 page
document.addEventListener('DOMContentLoaded', () => {
const errorContainer = document.querySelector('.error-container');
const errorDigit = document.querySelectorAll('.error-digit');
// Random glitch effect for the error digits
function glitchEffect() {
const randomDigit = errorDigit[Math.floor(Math.random() * errorDigit.length)];
randomDigit.style.opacity = '0.8';
randomDigit.style.transform = `translateX(${Math.random() * 5 - 2.5}px)`;
setTimeout(() => {
randomDigit.style.opacity = '1';
randomDigit.style.transform = 'translateX(0)';
}, 100);
}
// Create data corruption effect in background
function createCorruptionEffect() {
const corruptionLine = document.createElement('div');
corruptionLine.classList.add('corruption-line');
// Random positioning and styling
const top = Math.random() * 100;
const width = Math.random() * 100;
const duration = Math.random() * 2 + 0.5;
corruptionLine.style.cssText = `
position: absolute;
top: ${top}%;
left: 0;
height: 1px;
width: ${width}%;
background: rgba(6, 182, 212, 0.4);
z-index: 0;
transform: translateX(-100%);
animation: slideRight ${duration}s forwards ease-out;
`;
errorContainer.appendChild(corruptionLine);
// Remove after animation is complete
setTimeout(() => {
corruptionLine.remove();
}, duration * 1000);
}
// Set intervals for effects
setInterval(glitchEffect, 3000);
setInterval(createCorruptionEffect, 2000);
// Add keyframe animation dynamically
const style = document.createElement('style');
style.innerHTML = `
@keyframes slideRight {
0% { transform: translateX(-100%); }
100% { transform: translateX(100vw); }
}
`;
document.head.appendChild(style);
});
</script>

View File

@ -0,0 +1,166 @@
---
// Digital Garden Graph Visualization
// This component creates a network graph visualization of content relationships
---
<div class="digital-garden-graph">
<div class="graph-container">
<svg id="digital-garden-svg" width="100%" height="400"></svg>
</div>
<div class="graph-legend">
<span class="legend-item">
<span class="dot post-dot"></span>
<span>Blog Posts</span>
</span>
<span class="legend-item">
<span class="dot config-dot"></span>
<span>Configurations</span>
</span>
<span class="legend-item">
<span class="dot project-dot"></span>
<span>Projects</span>
</span>
</div>
</div>
<style>
.digital-garden-graph {
margin: 2rem 0;
border-radius: 1rem;
border: 1px solid var(--card-border);
background-color: var(--card-bg);
overflow: hidden;
}
.graph-container {
width: 100%;
height: 400px;
position: relative;
}
.graph-legend {
display: flex;
justify-content: center;
gap: 2rem;
padding: 1rem;
border-top: 1px solid var(--card-border);
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--text-secondary);
font-size: 0.85rem;
}
.dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
}
.post-dot {
background-color: var(--accent-primary);
}
.config-dot {
background-color: var(--accent-secondary);
}
.project-dot {
background-color: var(--accent-tertiary);
}
</style>
<script>
document.addEventListener('DOMContentLoaded', () => {
const svg = document.getElementById('digital-garden-svg');
if (!svg) return;
// Set up the SVG dimensions
const width = svg.clientWidth;
const height = 400;
// Define nodes - this would typically come from your content metadata
// For this demo, we'll create a sample dataset
const nodes = [
{ id: 'k3s-cluster', type: 'post', x: width * 0.2, y: height * 0.3 },
{ id: 'prometheus', type: 'post', x: width * 0.4, y: height * 0.5 },
{ id: 'cloudflare-tunnel', type: 'post', x: width * 0.6, y: height * 0.3 },
{ id: 'gitops-flux', type: 'post', x: width * 0.5, y: height * 0.7 },
{ id: 'longhorn-storage', type: 'config', x: width * 0.3, y: height * 0.6 },
{ id: 'metallb', type: 'config', x: width * 0.7, y: height * 0.5 },
{ id: 'argobox', type: 'project', x: width * 0.5, y: height * 0.2 },
];
// Define links between nodes
const links = [
{ source: 'k3s-cluster', target: 'prometheus' },
{ source: 'k3s-cluster', target: 'longhorn-storage' },
{ source: 'k3s-cluster', target: 'argobox' },
{ source: 'prometheus', target: 'gitops-flux' },
{ source: 'cloudflare-tunnel', target: 'argobox' },
{ source: 'longhorn-storage', target: 'prometheus' },
{ source: 'argobox', target: 'metallb' },
{ source: 'metallb', target: 'cloudflare-tunnel' },
];
// Create SVG elements
// First the links
links.forEach(link => {
const sourceNode = nodes.find(n => n.id === link.source);
const targetNode = nodes.find(n => n.id === link.target);
if (sourceNode && targetNode) {
const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', sourceNode.x);
line.setAttribute('y1', sourceNode.y);
line.setAttribute('x2', targetNode.x);
line.setAttribute('y2', targetNode.y);
line.setAttribute('stroke', 'rgba(226, 232, 240, 0.1)');
line.setAttribute('stroke-width', '2');
svg.appendChild(line);
}
});
// Then the nodes
nodes.forEach(node => {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', node.x);
circle.setAttribute('cy', node.y);
circle.setAttribute('r', '8');
// Assign color based on node type
if (node.type === 'post') {
circle.setAttribute('fill', 'var(--accent-primary)');
} else if (node.type === 'config') {
circle.setAttribute('fill', 'var(--accent-secondary)');
} else {
circle.setAttribute('fill', 'var(--accent-tertiary)');
}
// Add hover effect
circle.onmouseover = () => {
circle.setAttribute('r', '12');
circle.style.cursor = 'pointer';
};
circle.onmouseout = () => {
circle.setAttribute('r', '8');
};
// Add node label
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', node.x);
text.setAttribute('y', node.y + 20);
text.setAttribute('font-size', '12');
text.setAttribute('fill', 'var(--text-secondary)');
text.setAttribute('text-anchor', 'middle');
text.textContent = node.id.replace(/-/g, ' ');
svg.appendChild(circle);
svg.appendChild(text);
});
});
</script>

642
src/components/Footer.astro Normal file
View File

@ -0,0 +1,642 @@
---
// Footer.astro
// High-quality footer with navigation, social links and additional elements
const currentYear = new Date().getFullYear();
// Define categories for footer links
const categories = [
{
title: 'Technology',
links: [
{ name: 'Kubernetes', path: '/blog/category/kubernetes' },
{ name: 'Docker', path: '/blog/category/docker' },
{ name: 'DevOps', path: '/blog/category/devops' },
{ name: 'Networking', path: '/blog/category/networking' },
{ name: 'Storage', path: '/blog/category/storage' }
]
},
{
title: 'Resources',
links: [
{ name: 'K8s Configurations', path: '/resources/kubernetes' },
{ name: 'Docker Compose', path: '/resources/docker-compose' },
{ name: 'Configuration Files', path: '/resources/config-files' },
{ name: 'Infrastructure Code', path: '/resources/iac' },
{ name: 'Tutorials', path: '/resources/tutorials' }
]
},
{
title: 'Projects',
links: [
{ name: 'HomeLab Setup', path: '/projects/homelab' },
{ name: 'Tech Stack', path: '/projects/tech-stack' },
{ name: 'Github Repos', path: '/projects/github' },
{ name: 'Live Services', path: '/projects/services' },
{ name: 'Obsidian Templates', path: '/projects/obsidian' }
]
}
];
// Social links
const socialLinks = [
{
name: 'GitHub',
url: 'https://github.com/yourusername',
icon: '<path fill-rule="evenodd" clip-rule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385c.6.105.825-.255.825-.57c0-.285-.015-1.23-.015-2.235c-3.015.555-3.795-.735-4.035-1.41c-.135-.345-.72-1.41-1.23-1.695c-.42-.225-1.02-.78-.015-.795c.945-.015 1.62.87 1.845 1.23c1.08 1.815 2.805 1.305 3.495.99c.105-.78.42-1.305.765-1.605c-2.67-.3-5.46-1.335-5.46-5.925c0-1.305.465-2.385 1.23-3.225c-.12-.3-.54-1.53.12-3.18c0 0 1.005-.315 3.3 1.23c.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23c.66 1.65.24 2.88.12 3.18c.765.84 1.23 1.905 1.23 3.225c0 4.605-2.805 5.625-5.475 5.925c.435.375.81 1.095.81 2.22c0 1.605-.015 2.895-.015 3.3c0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />'
},
{
name: 'Twitter',
url: 'https://twitter.com/yourusername',
icon: '<path d="M23.643 4.937c-.835.37-1.732.62-2.675.733a4.67 4.67 0 0 0 2.048-2.578 9.3 9.3 0 0 1-2.958 1.13 4.66 4.66 0 0 0-7.938 4.25 13.229 13.229 0 0 1-9.602-4.868c-.4.69-.63 1.49-.63 2.342A4.66 4.66 0 0 0 3.96 9.824a4.647 4.647 0 0 1-2.11-.583v.06a4.66 4.66 0 0 0 3.737 4.568 4.692 4.692 0 0 1-2.104.08 4.661 4.661 0 0 0 4.352 3.234 9.348 9.348 0 0 1-5.786 1.995 9.5 9.5 0 0 1-1.112-.065 13.175 13.175 0 0 0 7.14 2.093c8.57 0 13.255-7.098 13.255-13.254 0-.2-.005-.402-.014-.602a9.47 9.47 0 0 0 2.323-2.41l.002-.003z" />'
},
{
name: 'LinkedIn',
url: 'https://linkedin.com/in/yourusername',
icon: '<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 0 1-2.063-2.065 2.064 2.064 0 1 1 2.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.225 0z" />'
},
{
name: 'RSS Feed',
url: '/rss.xml',
icon: '<path d="M4 11a9 9 0 0 1 9 9M4 4a16 16 0 0 1 16 16M6 19a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"></path>'
}
];
// Server status for homelab services
const services = [
{ name: 'ArgoBox', status: 'active' },
{ name: 'Git Server', status: 'active' },
{ name: 'Kubernetes', status: 'active' },
{ name: 'Media Server', status: 'active' }
];
---
<footer class="site-footer">
<!-- Network Lines Animation -->
<div class="network-lines"></div>
<!-- Floating Gradient Elements -->
<div class="footer-gradients">
<div class="gradient-circle circle-1"></div>
<div class="gradient-circle circle-2"></div>
</div>
<div class="container">
<!-- Main Footer Content -->
<div class="footer-content">
<!-- Brand Section -->
<div class="footer-brand">
<div class="footer-logo">
<div class="logo-symbol">
<span class="logo-text">LF</span>
<div class="logo-glow"></div>
</div>
<span class="footer-brand-name">LaForceIT</span>
</div>
<p class="footer-tagline">
Enterprise-grade home lab infrastructure, Kubernetes deployments, and DevOps automation for the modern tech enthusiast.
</p>
<!-- Homelab Services Status -->
<div class="service-status">
<h4 class="status-title">Services Status</h4>
<div class="status-grid">
{services.map(service => (
<div class="status-item">
<span class={`status-indicator ${service.status}`}></span>
<span class="status-name">{service.name}</span>
</div>
))}
</div>
</div>
<!-- Social Links -->
<div class="social-links">
{socialLinks.map(social => (
<a href={social.url} class="social-link" aria-label={social.name} target="_blank" rel="noopener">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<Fragment set:html={social.icon} />
</svg>
</a>
))}
</div>
</div>
<!-- Category Links -->
<div class="footer-links-container">
{categories.map(category => (
<div class="footer-col">
<h4 class="footer-col-title">{category.title}</h4>
<ul class="footer-links">
{category.links.map(link => (
<li>
<a href={link.path} class="footer-link">{link.name}</a>
</li>
))}
</ul>
</div>
))}
<!-- Newsletter Signup -->
<div class="footer-col">
<h4 class="footer-col-title">Newsletter</h4>
<p class="footer-newsletter-text">
Subscribe to get updates on new articles, resources, and projects.
</p>
<form class="newsletter-form">
<div class="newsletter-input-group">
<input type="email" placeholder="Enter your email" class="newsletter-input" required />
<button type="submit" class="newsletter-button">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Footer Bottom -->
<div class="footer-bottom">
<div class="copyright">
&copy; {currentYear} LaForceIT by Daniel LaForce. All rights reserved.
</div>
<div class="footer-meta-links">
<a href="/privacy" class="meta-link">Privacy Policy</a>
<span class="link-divider">|</span>
<a href="/terms" class="meta-link">Terms of Use</a>
<span class="link-divider">|</span>
<a href="/sitemap.xml" class="meta-link">Sitemap</a>
</div>
</div>
</div>
</footer>
<style>
.site-footer {
background: linear-gradient(0deg, var(--bg-secondary), var(--bg-primary));
padding: 5rem 0 2rem;
position: relative;
border-top: 1px solid var(--border-primary);
overflow: hidden;
}
/* Network Lines Animation */
.network-lines {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 1px;
overflow: hidden;
opacity: 0.5;
}
.network-lines::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, var(--accent-primary) 50%, transparent 100%);
animation: network-scan 8s infinite linear;
}
@keyframes network-scan {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* Floating Gradient Elements */
.footer-gradients {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.gradient-circle {
position: absolute;
border-radius: 50%;
filter: blur(70px);
opacity: 0.08;
}
.circle-1 {
width: 400px;
height: 400px;
background: var(--accent-primary);
top: -100px;
left: -150px;
}
.circle-2 {
width: 300px;
height: 300px;
background: var(--accent-tertiary);
bottom: -50px;
right: -100px;
}
/* Main Footer Content */
.footer-content {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 3rem;
position: relative;
z-index: 1;
}
/* Brand Section */
.footer-brand {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.footer-logo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.logo-symbol {
width: 2.5rem;
height: 2.5rem;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.logo-text {
font-family: var(--font-mono);
font-weight: bold;
font-size: 1.1rem;
color: var(--bg-primary);
z-index: 2;
}
.logo-glow {
position: absolute;
width: 150%;
height: 150%;
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 70%);
opacity: 0.5;
filter: blur(15px);
z-index: 1;
animation: pulse 4s infinite alternate ease-in-out;
}
.footer-brand-name {
font-weight: 600;
font-size: 1.3rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: 0.02em;
}
.footer-tagline {
color: var(--text-secondary);
font-size: 0.95rem;
line-height: 1.6;
max-width: 350px;
}
/* Service Status */
.service-status {
background: rgba(15, 23, 42, 0.4);
border: 1px solid var(--border-secondary);
border-radius: 8px;
padding: 1rem;
margin-top: 1rem;
}
.status-title {
font-size: 0.9rem;
color: var(--text-secondary);
margin-bottom: 0.75rem;
font-family: var(--font-mono);
letter-spacing: 0.05em;
}
.status-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
}
.status-indicator.active {
background: #10b981; /* Green */
}
.status-indicator.active::after {
content: '';
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background: #10b981;
opacity: 0.5;
animation: pulse 2s infinite;
}
.status-indicator.warning {
background: #f59e0b; /* Amber */
}
.status-indicator.offline {
background: #ef4444; /* Red */
}
.status-name {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Social Links */
.social-links {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.social-link {
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(226, 232, 240, 0.05);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
transition: all 0.3s ease;
}
.social-link:hover {
background: var(--accent-primary);
color: var(--bg-primary);
transform: translateY(-3px);
box-shadow: 0 5px 15px var(--glow-primary);
}
/* Footer Links */
.footer-links-container {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2rem;
}
.footer-col-title {
color: var(--text-primary);
font-size: 1.1rem;
margin-bottom: 1.5rem;
position: relative;
}
.footer-col-title::after {
content: '';
position: absolute;
left: 0;
bottom: -0.5rem;
width: 30px;
height: 2px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
}
.footer-links {
list-style: none;
padding: 0;
margin: 0;
}
.footer-links li {
margin-bottom: 0.75rem;
}
.footer-link {
color: var(--text-secondary);
transition: all 0.3s ease;
position: relative;
display: inline-block;
}
.footer-link:hover {
color: var(--accent-primary);
transform: translateX(3px);
}
.footer-link::before {
content: '→';
position: absolute;
left: -18px;
opacity: 0;
transition: all 0.3s ease;
color: var(--accent-primary);
}
.footer-link:hover::before {
opacity: 1;
left: -15px;
}
/* Newsletter */
.footer-newsletter-text {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.newsletter-input-group {
display: flex;
position: relative;
}
.newsletter-input {
width: 100%;
background: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border-secondary);
padding: 0.75rem 1rem;
border-radius: 8px;
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
transition: all 0.3s ease;
}
.newsletter-input:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px var(--glow-primary);
}
.newsletter-button {
position: absolute;
right: 5px;
top: 50%;
transform: translateY(-50%);
background: var(--accent-primary);
border: none;
color: var(--bg-primary);
width: 36px;
height: 36px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
}
.newsletter-button:hover {
background: var(--accent-secondary);
transform: translateY(-50%) scale(1.05);
}
/* Footer Bottom */
.footer-bottom {
margin-top: 4rem;
padding-top: 2rem;
border-top: 1px solid var(--border-secondary);
display: flex;
justify-content: space-between;
align-items: center;
color: var(--text-tertiary);
font-size: 0.9rem;
}
.footer-meta-links {
display: flex;
align-items: center;
gap: 0.5rem;
}
.meta-link {
color: var(--text-tertiary);
transition: color 0.3s ease;
}
.meta-link:hover {
color: var(--text-secondary);
}
.link-divider {
color: var(--text-tertiary);
opacity: 0.5;
}
@keyframes pulse {
0% {
opacity: 0.3;
transform: scale(0.8);
}
100% {
opacity: 0.6;
transform: scale(1.2);
}
}
/* Responsive Adjustments */
@media (max-width: 1024px) {
.footer-content {
grid-template-columns: 1fr;
gap: 3rem;
}
.footer-links-container {
grid-template-columns: repeat(2, 1fr);
}
.footer-tagline {
max-width: 100%;
}
}
@media (max-width: 768px) {
.site-footer {
padding: 3rem 0 2rem;
}
.footer-links-container {
grid-template-columns: 1fr;
gap: 2rem;
}
.status-grid {
grid-template-columns: 1fr;
}
.footer-bottom {
flex-direction: column;
gap: 1rem;
text-align: center;
}
}
</style>
<script>
// Create animated nodes in footer
document.addEventListener('DOMContentLoaded', () => {
const footer = document.querySelector('.site-footer');
if (footer) {
// Add network node animations
for (let i = 0; i < 8; i++) {
const node = document.createElement('div');
node.className = 'footer-node';
node.style.cssText = `
position: absolute;
width: 2px;
height: 2px;
background: rgba(226, 232, 240, 0.2);
border-radius: 50%;
left: ${Math.random() * 100}%;
top: ${Math.random() * 100}%;
animation: pulse ${4 + Math.random() * 4}s infinite alternate ease-in-out;
animation-delay: ${Math.random() * 5}s;
`;
footer.appendChild(node);
}
// Newsletter Form Submission
const newsletterForm = document.querySelector('.newsletter-form');
if (newsletterForm) {
newsletterForm.addEventListener('submit', (e) => {
e.preventDefault();
const emailInput = newsletterForm.querySelector('input[type="email"]');
if (emailInput && emailInput.value) {
alert(`Thank you for subscribing with ${emailInput.value}! We'll send you updates soon.`);
emailInput.value = '';
}
});
}
}
});
</script>

438
src/components/Header.astro Normal file
View File

@ -0,0 +1,438 @@
---
// Header.astro
// Primary navigation component with premium design elements
// Get current path to highlight active nav item
const pathname = new URL(Astro.request.url).pathname;
const currentPath = pathname.split('/')[1]; // Get the first path segment
// Define navigation items
const navItems = [
{ name: 'Home', path: '/' },
{ name: 'Blog', path: '/blog/' },
{ name: 'Projects', path: '/projects/' },
{ name: 'Home Lab', path: '/homelab/' },
{ name: 'Resources', path: '/resources/' },
{ name: 'About', path: '/about/' },
{ name: 'Contact', path: '/contact/' }
];
---
<header class="site-header">
<div class="nebula-bg"></div>
<div class="container">
<div class="header-container">
<a href="/" class="logo">
<div class="logo-symbol">
<span class="logo-text">LF</span>
<div class="logo-glow"></div>
</div>
<div class="logo-text-container">
<span class="logo-name">LaForceIT</span>
<span class="logo-tagline">Infrastructure & Automation</span>
</div>
</a>
<nav>
<div class="main-nav">
{navItems.map((item) => (
<a
href={item.path}
class={`nav-link ${currentPath === item.path.replace(/\//g, '') ? 'active' : ''}`}
>
{item.name}
<div class="nav-highlight"></div>
</a>
))}
</div>
<div class="header-actions">
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode">
<svg class="icon-sun" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
<svg class="icon-moon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</button>
<button id="search-button" class="search-button" aria-label="Search">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</button>
<button id="mobile-menu-btn" class="mobile-menu-btn" aria-label="Toggle menu">
<svg class="icon-menu" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
<svg class="icon-close" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</nav>
</div>
</div>
<!-- Animated network lines effect -->
<div class="network-lines"></div>
</header>
<style>
.site-header {
background: linear-gradient(180deg, rgba(15, 18, 25, 0.9), rgba(13, 16, 23, 0.8));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
padding: 1rem 0;
position: sticky;
top: 0;
z-index: 100;
transition: all 0.3s ease;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.site-header.scrolled {
padding: 0.6rem 0;
background: rgba(10, 12, 20, 0.95);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-primary);
text-decoration: none;
position: relative;
}
.logo-symbol {
width: 2.75rem;
height: 2.75rem;
border-radius: 10px;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
transition: transform 0.3s ease;
}
.logo:hover .logo-symbol {
transform: translateY(-3px);
}
.logo-text {
font-family: var(--font-mono);
font-weight: bold;
font-size: 1.25rem;
color: var(--bg-primary);
z-index: 2;
}
.logo-glow {
position: absolute;
width: 150%;
height: 150%;
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 70%);
opacity: 0.5;
filter: blur(15px);
z-index: 1;
animation: pulse 4s infinite alternate ease-in-out;
}
.logo-text-container {
display: flex;
flex-direction: column;
}
.logo-name {
font-weight: 600;
font-size: 1.4rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
letter-spacing: 0.02em;
}
.logo-tagline {
font-size: 0.75rem;
color: var(--text-secondary);
font-family: var(--font-mono);
letter-spacing: 0.05em;
}
.main-nav {
display: flex;
gap: 1.5rem;
align-items: center;
}
.nav-link {
color: var(--text-secondary);
position: relative;
text-decoration: none;
font-weight: 500;
transition: color 0.3s ease;
padding: 0.5rem 0;
}
.nav-link:hover, .nav-link.active {
color: var(--text-primary);
}
.nav-highlight {
position: absolute;
bottom: -2px;
left: 0;
width: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
transition: width 0.3s ease;
border-radius: 2px;
}
.nav-link:hover .nav-highlight,
.nav-link.active .nav-highlight {
width: 100%;
}
.header-actions {
display: flex;
align-items: center;
gap: 1rem;
margin-left: 2rem;
}
.theme-toggle,
.search-button {
background: none;
border: none;
color: var(--text-secondary);
padding: 0.5rem;
cursor: pointer;
border-radius: 50%;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.theme-toggle:hover,
.search-button:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1);
}
.icon-sun {
display: none;
}
:global(.light-mode) .icon-moon {
display: none;
}
:global(.light-mode) .icon-sun {
display: block;
}
.mobile-menu-btn {
display: none;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
}
.icon-close {
display: none;
}
.mobile-menu-active .icon-menu {
display: none;
}
.mobile-menu-active .icon-close {
display: block;
}
/* Animated network lines effect */
.network-lines {
position: absolute;
bottom: -1px;
left: 0;
width: 100%;
height: 1px;
overflow: hidden;
opacity: 0.5;
}
.network-lines::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, var(--accent-primary) 50%, transparent 100%);
animation: network-scan 8s infinite linear;
}
/* Nebula background */
.nebula-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle at 20% 50%, rgba(6, 182, 212, 0.1) 0%, transparent 50%),
radial-gradient(circle at 80% 50%, rgba(139, 92, 246, 0.1) 0%, transparent 50%);
opacity: 0.3;
z-index: -1;
}
@keyframes pulse {
0% {
opacity: 0.4;
transform: scale(0.8);
}
100% {
opacity: 0.8;
transform: scale(1.2);
}
}
@keyframes network-scan {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
/* Responsive Adjustments */
@media (max-width: 1024px) {
.main-nav {
gap: 1rem;
}
.header-actions {
margin-left: 1rem;
}
}
@media (max-width: 768px) {
.mobile-menu-btn {
display: block;
}
.main-nav {
display: none;
position: absolute;
top: 100%;
left: 0;
width: 100%;
background: var(--bg-secondary);
padding: 1rem;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
border-bottom: 1px solid var(--border-primary);
z-index: 10;
}
.main-nav.active {
display: flex;
}
.logo-tagline {
display: none;
}
.theme-toggle, .search-button {
padding: 0.4rem;
}
}
</style>
<script>
// Handle mobile menu toggle
document.addEventListener('DOMContentLoaded', () => {
const menuBtn = document.getElementById('mobile-menu-btn');
const mainNav = document.querySelector('.main-nav');
const header = document.querySelector('.site-header');
if (menuBtn && mainNav) {
menuBtn.addEventListener('click', () => {
mainNav.classList.toggle('active');
menuBtn.classList.toggle('mobile-menu-active');
});
}
// Header scroll effect
window.addEventListener('scroll', () => {
if (window.scrollY > 50) {
header.classList.add('scrolled');
} else {
header.classList.remove('scrolled');
}
});
// Theme toggle functionality
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', () => {
document.documentElement.classList.toggle('light-mode');
// Store preference in localStorage
const isLightMode = document.documentElement.classList.contains('light-mode');
localStorage.setItem('theme', isLightMode ? 'light' : 'dark');
});
// Apply saved theme preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light') {
document.documentElement.classList.add('light-mode');
}
}
// Add interactive network nodes animation
const header_el = document.querySelector('.site-header');
if (header_el) {
// Create animated nodes
for (let i = 0; i < 5; i++) {
const node = document.createElement('div');
node.className = 'nav-node';
node.style.left = `${Math.random() * 100}%`;
node.style.animationDelay = `${Math.random() * 5}s`;
node.style.animationDuration = `${5 + Math.random() * 5}s`;
header_el.appendChild(node);
}
}
});
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,439 @@
---
// src/components/KnowledgeGraph.astro
// Interactive visualization of content connections using Cytoscape.js
// Assuming Cytoscape is loaded via CDN in BaseLayout or globally
// If not, you might need: import cytoscape from 'cytoscape';
export interface GraphNode {
id: string;
label: string;
category?: string;
tags?: string[];
url?: string; // Added URL for linking
}
export interface GraphEdge {
source: string;
target: string;
strength?: number;
}
export interface GraphData {
nodes: GraphNode[];
edges: GraphEdge[];
}
interface Props {
graphData: GraphData;
height?: string; // e.g., '600px'
}
const { graphData, height = "60vh" } = Astro.props;
// Generate colors based on categories for nodes
const uniqueCategories = [...new Set(graphData.nodes.map(node => node.category || 'Uncategorized'))];
const categoryColors = {};
const predefinedColors = { /* Colors from previous step */
'Kubernetes': '#326CE5', 'Docker': '#2496ED', 'DevOps': '#FF6F61',
'Homelab': '#06B6D4', 'Networking': '#9333EA', 'Infrastructure': '#10B981',
'Automation': '#F59E0B', 'Security': '#EF4444', 'Monitoring': '#6366F1',
'Storage': '#8B5CF6', 'Obsidian': '#7C3AED', 'Tutorial': '#3B82F6',
'Uncategorized': '#A0AEC0'
};
uniqueCategories.forEach((category, index) => {
if (predefinedColors[category]) {
categoryColors[category] = predefinedColors[category];
} else {
const hue = (index * 137.5) % 360;
categoryColors[category] = `hsl(${hue}, 70%, 60%)`;
}
});
// Calculate node sizes
const nodeSizes = {};
const minSize = 20; const maxSize = 40;
const degreeMap = new Map();
graphData.nodes.forEach(node => degreeMap.set(node.id, 0));
graphData.edges.forEach(edge => {
degreeMap.set(edge.source, (degreeMap.get(edge.source) || 0) + 1);
degreeMap.set(edge.target, (degreeMap.get(edge.target) || 0) + 1);
});
const maxDegree = Math.max(...Array.from(degreeMap.values()), 1);
graphData.nodes.forEach(node => {
const degree = degreeMap.get(node.id) || 0;
const normalizedSize = maxDegree === 0 ? 0.5 : degree / maxDegree;
nodeSizes[node.id] = minSize + normalizedSize * (maxSize - minSize);
});
---
<div class="graph-wrapper" style={`--graph-height: ${height};`}>
{/* Loading Animation */}
<div id="graph-loading" class="graph-loading">
<div class="loading-spinner">
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
<div class="spinner-ring"></div>
</div>
<div class="loading-text">Initializing Knowledge Graph...</div>
</div>
{/* Cytoscape Container */}
<div id="knowledge-graph" class="graph-container"></div>
{/* Node Details Panel */}
<div id="node-details" class="node-details">
<div class="node-details-header">
<h3 id="node-title" class="node-title">Node Title</h3>
<button id="close-details" class="close-button" aria-label="Close details">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div id="node-category" class="node-category">
<span class="category-label">Category:</span>
<span class="category-value">Category Name</span>
</div>
<div id="node-tags" class="node-tags">
<span class="tags-label">Tags:</span>
<div class="tags-container">
{/* Tags populated by JS */}
</div>
</div>
<div id="node-connections" class="node-connections">
<span class="connections-label">Connections:</span>
<ul class="connections-list">
{/* Connections populated by JS */}
</ul>
</div>
<a href="#" id="node-link" class="node-link" target="_blank" rel="noopener noreferrer">Read Article</a>
</div>
{/* Graph Controls */}
<div class="graph-controls">
<div class="graph-filters">
<button class="graph-filter active" data-filter="all" style="--filter-color: var(--accent-primary);">All Topics</button>
{uniqueCategories.map(category => (
<button class="graph-filter" data-filter={category} style={`--filter-color: ${categoryColors[category]};`}>{category}</button>
))}
</div>
<div class="graph-actions">
<button id="zoom-in" class="graph-action" aria-label="Zoom In">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
<button id="zoom-out" class="graph-action" aria-label="Zoom Out">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"></line></svg>
</button>
<button id="reset-graph" class="graph-action" aria-label="Reset View">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 2v6h6"></path><path d="M21 12A9 9 0 0 0 6 5.3L3 8"></path><path d="M21 22v-6h-6"></path><path d="M3 12a9 9 0 0 0 15 6.7l3-2.7"></path></svg>
</button>
</div>
</div>
{/* Graph Legend */}
<details class="graph-legend">
<summary class="legend-title">Legend</summary>
<div class="legend-items">
{uniqueCategories.map(category => (
<div class="legend-item" data-category={category}>
<span class="legend-color" style={`background-color: ${categoryColors[category]};`}></span>
<span class="legend-label">{category}</span>
</div>
))}
</div>
</details>
</div>
{/* Include Cytoscape via CDN - Ensure this is loaded, perhaps in BaseLayout */}
{/* <script src="https://unpkg.com/cytoscape@3.25.0/dist/cytoscape.min.js"></script> */}
<script define:vars={{ graphData, categoryColors, nodeSizes }}>
// Initialize the graph when the DOM is ready
function initializeGraph() {
// Check if Cytoscape is loaded
if (typeof cytoscape === 'undefined') {
console.error("Cytoscape library not loaded. Make sure it's included (e.g., via CDN in BaseLayout).");
const loadingEl = document.getElementById('graph-loading');
if(loadingEl) loadingEl.innerHTML = "<p>Error: Cytoscape library not loaded.</p>";
return;
}
const loadingEl = document.getElementById('graph-loading');
const nodeDetailsEl = document.getElementById('node-details');
const closeDetailsBtn = document.getElementById('close-details');
const graphContainer = document.getElementById('knowledge-graph');
if (!graphContainer) {
console.error("Knowledge graph container not found!");
return;
}
if (!graphData || !graphData.nodes) {
console.error("Graph data is missing or invalid.");
if(loadingEl) loadingEl.innerHTML = "<p>Error loading graph data.</p>";
return;
}
// Format data for Cytoscape
const elements = [];
graphData.nodes.forEach(node => {
elements.push({
data: {
id: node.id,
label: node.label,
category: node.category || 'Uncategorized',
tags: node.tags || [],
size: nodeSizes[node.id] || 25,
color: categoryColors[node.category || 'Uncategorized'] || '#A0AEC0',
url: `/posts/${node.id}/`
}
});
});
graphData.edges.forEach((edge, index) => {
if (graphData.nodes.some(n => n.id === edge.source) && graphData.nodes.some(n => n.id === edge.target)) {
elements.push({
data: { id: `e${index}`, source: edge.source, target: edge.target, weight: edge.strength || 1 }
});
} else {
console.warn(`Skipping edge e${index} due to missing node: ${edge.source} -> ${edge.target}`);
}
});
// Initialize Cytoscape
const cy = cytoscape({
container: graphContainer,
elements: elements,
style: [ /* Styles from your snippet */
{ selector: 'node', style: { 'background-color': 'data(color)', 'label': 'data(label)', 'width': 'data(size)', 'height': 'data(size)', 'font-size': '10px', 'color': '#E2E8F0', 'text-valign': 'bottom', 'text-halign': 'center', 'text-margin-y': '7px', 'text-background-opacity': 0.7, 'text-background-color': '#0F1219', 'text-background-padding': '3px', 'text-background-shape': 'roundrectangle', 'text-max-width': '120px', 'text-wrap': 'ellipsis', 'text-overflow-wrap': 'anywhere', 'border-width': '2px', 'border-color': '#0F1219', 'border-opacity': 0.8, 'z-index': 10, 'text-outline-width': 1, 'text-outline-color': '#000', 'text-outline-opacity': 0.5 } },
{ selector: 'edge', style: { 'width': 'mapData(weight, 1, 10, 1, 4)', 'line-color': 'rgba(226, 232, 240, 0.2)', 'curve-style': 'bezier', 'opacity': 0.6, 'z-index': 1 } },
{ selector: '.highlighted', style: { 'background-color': 'data(color)', 'border-color': '#FFFFFF', 'border-width': '3px', 'color': '#FFFFFF', 'text-background-opacity': 0.9, 'opacity': 1, 'z-index': 20 } },
{ selector: '.filtered', style: { 'background-color': 'data(color)', 'border-color': '#FFFFFF', 'border-width': '2px', 'color': '#FFFFFF', 'text-background-opacity': 0.8, 'opacity': 0.8, 'z-index': 15 } },
{ selector: '.faded', style: { 'opacity': 0.15, 'text-opacity': 0.3, 'background-opacity': 0.3, 'z-index': 1 } },
{ selector: 'node:selected', style: { 'border-width': '4px', 'border-color': '#FFFFFF', 'border-opacity': 1, 'background-color': 'data(color)', 'text-opacity': 1, 'color': '#FFFFFF', 'z-index': 30 } },
{ selector: 'edge:selected', style: { 'width': 'mapData(weight, 1, 10, 2, 6)', 'line-color': '#FFFFFF', 'opacity': 1, 'z-index': 30 } }
],
layout: { name: 'cose', idealEdgeLength: 100, nodeOverlap: 20, refresh: 20, fit: true, padding: 30, randomize: false, componentSpacing: 100, nodeRepulsion: 400000, edgeElasticity: 100, nestingFactor: 5, gravity: 80, numIter: 1000, initialTemp: 200, coolingFactor: 0.95, minTemp: 1.0 },
zoom: 1, minZoom: 0.1, maxZoom: 4, zoomingEnabled: true, userZoomingEnabled: true, panningEnabled: true, userPanningEnabled: true, boxSelectionEnabled: false,
});
// Hide loading screen
if (loadingEl) {
setTimeout(() => { loadingEl.classList.add('hidden'); }, 500);
}
// --- Interactions ---
let hoverTimeout;
cy.on('mouseover', 'node', function(e) {
const node = e.target;
clearTimeout(hoverTimeout);
node.addClass('highlighted');
node.connectedEdges().addClass('highlighted');
graphContainer.style.cursor = 'pointer'; // Use graphContainer
});
cy.on('mouseout', 'node', function(e) {
const node = e.target;
hoverTimeout = setTimeout(() => {
if (!node.selected()) {
node.removeClass('highlighted');
node.connectedEdges().removeClass('highlighted');
}
}, 100);
graphContainer.style.cursor = 'default'; // Use graphContainer
});
cy.on('tap', 'node', function(e) {
const node = e.target;
const nodeData = node.data();
if (nodeDetailsEl) {
document.getElementById('node-title').textContent = nodeData.label;
const categoryEl = document.getElementById('node-category').querySelector('.category-value');
categoryEl.textContent = nodeData.category;
// Use categoryColors map correctly
const catColor = categoryColors[nodeData.category] || 'var(--text-secondary)';
categoryEl.style.backgroundColor = `${catColor}33`; // Add alpha
categoryEl.style.color = catColor;
const tagsContainer = document.getElementById('node-tags').querySelector('.tags-container');
tagsContainer.innerHTML = '';
if (nodeData.tags && nodeData.tags.length > 0) {
nodeData.tags.forEach(tag => {
const tagEl = document.createElement('span');
tagEl.className = 'tag';
tagEl.textContent = tag;
tagsContainer.appendChild(tagEl);
});
} else {
tagsContainer.innerHTML = '<span class="no-tags">No tags</span>';
}
const connectionsList = document.getElementById('node-connections').querySelector('.connections-list');
connectionsList.innerHTML = '';
const connectedNodes = node.neighborhood('node');
if (connectedNodes.length > 0) {
connectedNodes.forEach(connectedNode => {
const connectedData = connectedNode.data();
const listItem = document.createElement('li');
const link = document.createElement('a');
link.href = '#';
link.textContent = connectedData.label;
link.dataset.id = connectedData.id;
link.addEventListener('click', (evt) => {
evt.preventDefault();
cy.$(':selected').unselect();
const targetNode = cy.getElementById(connectedData.id);
if (targetNode) { // Check if node exists
targetNode.select();
cy.animate({ center: { eles: targetNode }, zoom: cy.zoom() }, { duration: 300 });
targetNode.trigger('tap');
}
});
listItem.appendChild(link);
connectionsList.appendChild(listItem);
});
} else {
connectionsList.innerHTML = '<li>No connections</li>';
}
document.getElementById('node-link').href = nodeData.url;
nodeDetailsEl.classList.add('active');
}
cy.elements().removeClass('highlighted').removeClass('faded');
node.addClass('highlighted');
node.neighborhood().addClass('highlighted');
cy.elements().difference(node.neighborhood().union(node)).addClass('faded');
});
cy.on('tap', function(e) {
if (e.target === cy) {
if (nodeDetailsEl) nodeDetailsEl.classList.remove('active');
cy.elements().removeClass('selected highlighted faded');
}
});
if (closeDetailsBtn) {
closeDetailsBtn.addEventListener('click', () => {
if (nodeDetailsEl) nodeDetailsEl.classList.remove('active');
cy.$(':selected').unselect();
cy.elements().removeClass('highlighted faded');
});
}
// Category filtering
const filterButtons = document.querySelectorAll('.graph-filter');
filterButtons.forEach(button => {
button.addEventListener('click', () => {
filterButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
const category = button.dataset.filter;
if (category === 'all') {
cy.elements().removeClass('faded highlighted filtered');
} else {
cy.elements().addClass('faded').removeClass('highlighted filtered');
const selectedNodes = cy.nodes().filter(node => node.data('category') === category);
selectedNodes.removeClass('faded').addClass('filtered');
selectedNodes.connectedEdges().removeClass('faded');
}
});
});
// Legend item interactions
const legendItems = document.querySelectorAll('.legend-item');
legendItems.forEach(item => {
item.addEventListener('click', () => {
const category = item.dataset.category;
const filterButton = document.querySelector(`.graph-filter[data-filter="${category}"]`);
filterButton?.click();
});
});
// Zoom controls
document.getElementById('zoom-in')?.addEventListener('click', () => cy.zoom(cy.zoom() * 1.2));
document.getElementById('zoom-out')?.addEventListener('click', () => cy.zoom(cy.zoom() / 1.2));
document.getElementById('reset-graph')?.addEventListener('click', () => {
cy.fit(null, 30);
cy.elements().removeClass('faded highlighted filtered');
document.querySelector('.graph-filter[data-filter="all"]')?.click();
});
// Add mouse wheel zoom controls (already present in original script)
cy.on('zoom', function() {
if (cy.zoom() > 1.5) {
cy.style().selector('node').style({ 'text-max-width': '150px', 'font-size': '12px' }).update();
} else {
cy.style().selector('node').style({ 'text-max-width': '120px', 'font-size': '10px' }).update();
}
});
// Dispatch graphReady event
document.dispatchEvent(new CustomEvent('graphReady', { detail: { cy } }));
}
// Initialize graph on DOMContentLoaded or if already loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeGraph);
} else {
initializeGraph();
}
</script>
<style>
/* Styles from user snippet */
.graph-wrapper { position: relative; width: 100%; height: var(--graph-height, 60vh); min-height: 500px; max-height: 800px; }
.graph-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border-radius: 12px; overflow: hidden; border: 1px solid var(--card-border); background: rgba(15, 23, 42, 0.2); backdrop-filter: blur(5px); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15); }
#knowledge-graph { width: 100%; height: 100%; z-index: 1; }
.graph-loading { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15, 23, 42, 0.7); z-index: 10; transition: opacity 0.5s ease, visibility 0.5s ease; }
.graph-loading.hidden { opacity: 0; visibility: hidden; }
.loading-spinner { position: relative; width: 80px; height: 80px; margin-bottom: 1rem; }
.spinner-ring { position: absolute; width: 100%; height: 100%; border-radius: 50%; border: 3px solid transparent; border-top-color: var(--accent-primary); animation: spin 1.5s linear infinite; }
.spinner-ring:nth-child(2) { width: calc(100% - 15px); height: calc(100% - 15px); top: 7.5px; left: 7.5px; border-top-color: var(--accent-secondary); animation-duration: 2s; animation-direction: reverse; }
.spinner-ring:nth-child(3) { width: calc(100% - 30px); height: calc(100% - 30px); top: 15px; left: 15px; border-top-color: var(--accent-tertiary); animation-duration: 2.5s; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-text { color: var(--text-primary); font-family: var(--font-mono); font-size: 1rem; letter-spacing: 0.05em; }
.node-details { position: absolute; top: 20px; right: 20px; width: 300px; background: var(--bg-secondary); border: 1px solid var(--card-border); border-radius: 10px; padding: 1.5rem; z-index: 5; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); transform: translateX(120%); transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); opacity: 0; backdrop-filter: blur(10px); }
.node-details.active { transform: translateX(0); opacity: 1; }
.node-details-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 1.25rem; }
.node-title { font-size: 1.2rem; margin: 0; color: var(--text-primary); font-weight: 600; line-height: 1.3; }
.close-button { background: none; border: none; color: var(--text-secondary); cursor: pointer; padding: 5px; border-radius: 50%; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; }
.close-button:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.1); }
.node-category, .node-tags, .node-connections { margin-bottom: 1.25rem; }
.category-label, .tags-label, .connections-label { display: block; color: var(--text-secondary); font-size: 0.85rem; margin-bottom: 0.5rem; font-family: var(--font-mono); }
.category-value { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.85rem; font-family: var(--font-mono); }
.tags-container { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.tag { background: rgba(226, 232, 240, 0.05); color: var(--text-secondary); padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-family: var(--font-mono); }
.no-tags { color: var(--text-tertiary); font-style: italic; font-size: 0.8rem; }
.connections-list { padding-left: 0; list-style: none; margin: 0; max-height: 150px; overflow-y: auto; }
.connections-list li { color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.5rem; }
.connections-list a { color: var(--accent-primary); text-decoration: none; transition: color 0.2s ease; }
.connections-list a:hover { color: var(--accent-secondary); text-decoration: underline; }
.node-link { display: block; background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); color: var(--bg-primary); font-weight: 500; padding: 0.6rem 1.25rem; border-radius: 6px; text-decoration: none; text-align: center; transition: all 0.3s ease; box-shadow: 0 4px 10px rgba(6, 182, 212, 0.2); margin-top: 1rem; }
.node-link:hover { transform: translateY(-2px); box-shadow: 0 6px 15px rgba(6, 182, 212, 0.3); }
.graph-controls { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); display: flex; flex-direction: column; gap: 1rem; z-index: 5; }
.graph-filters { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; padding: 0.75rem; background: rgba(15, 23, 42, 0.7); backdrop-filter: blur(10px); border-radius: 30px; border: 1px solid var(--border-primary); }
.graph-filter { background: rgba(226, 232, 240, 0.05); color: var(--text-secondary); border: 1px solid var(--border-secondary); padding: 0.4rem 0.8rem; border-radius: 30px; font-size: 0.8rem; cursor: pointer; transition: all 0.2s ease; font-family: var(--font-mono); position: relative; overflow: hidden; }
.graph-filter::before { content: ''; position: absolute; top: 0; left: 0; width: 0; height: 100%; background: var(--filter-color, 'transparent'); opacity: 0.15; transition: width 0.3s ease; }
.graph-filter:hover::before { width: 100%; }
.graph-filter:hover { color: var(--text-primary); border-color: var(--filter-color, rgba(56, 189, 248, 0.4)); transform: translateY(-2px); }
.graph-filter.active { background-color: var(--filter-color, var(--accent-primary)); color: var(--bg-primary); border-color: var(--filter-color, var(--accent-primary)); font-weight: 600; }
.graph-filter.active::before { width: 100%; opacity: 0.2; }
.graph-actions { display: flex; justify-content: center; gap: 0.75rem; }
.graph-action { width: 36px; height: 36px; border-radius: 50%; background: var(--bg-secondary); border: 1px solid var(--border-primary); color: var(--text-secondary); display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; }
.graph-action:hover { color: var(--text-primary); background: var(--bg-tertiary); transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); }
.graph-legend { position: absolute; top: 20px; left: 20px; background: rgba(15, 23, 42, 0.7); border: 1px solid var(--card-border); border-radius: 10px; padding: 1rem; z-index: 5; max-width: 200px; backdrop-filter: blur(10px); }
.legend-title { font-size: 0.9rem; color: var(--text-primary); margin-bottom: 0.75rem; font-weight: 600; font-family: var(--font-mono); cursor: pointer; }
.legend-items { display: flex; flex-direction: column; gap: 0.5rem; max-height: 200px; overflow-y: auto; }
.graph-legend:not([open]) .legend-items { display: none; }
.legend-item { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; transition: all 0.2s ease; padding: 0.25rem 0.5rem; border-radius: 4px; }
.legend-item:hover { background: rgba(255, 255, 255, 0.05); }
.legend-color { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
.legend-label { font-size: 0.8rem; color: var(--text-secondary); }
@media screen and (max-width: 768px) {
.node-details { width: 85%; max-width: 300px; left: 50%; right: auto; transform: translate(-50%, 120%); bottom: 20px; top: auto; }
.node-details.active { transform: translate(-50%, 0); }
.graph-legend { transform: translateX(-120%); transition: transform 0.3s ease; max-height: calc(100% - 40px); overflow-y: auto; }
.graph-legend[open] { transform: translateX(0); }
.graph-controls { bottom: 10px; }
}
</style>

View File

View File

@ -0,0 +1,53 @@
---
interface Props {
title: string;
excerpt: string;
date: string;
category: string;
image: string;
url: string;
readTime?: string;
}
const {
title,
excerpt,
date,
category,
image,
url,
readTime = "5 min read"
} = Astro.props;
---
<article class="post-card">
<a href={url} aria-label={`Read more about ${title}`}>
<img src={image} alt="" class="post-image" width="720" height="360" loading="lazy" /> {/* Added alt="", loading */}
</a>
<div class="post-content">
<div class="post-meta">
<span class="post-date">{date}</span>
{category && <span class="post-category">{category}</span>}
</div>
<h3 class="post-title">
<a href={url}>{title}</a>
</h3>
<p class="post-excerpt">
{excerpt}
</p>
<div class="post-footer">
<span class="post-read-time">{readTime}</span>
<a href={url} class="read-more">Read More</a>
</div>
</div>
</article>
<style>
/* Styles are in global.css */
.post-card a:has(img) { /* Ensure image link covers image */
display: block;
}
.post-image {
background-color: var(--bg-secondary); /* Add bg color for loading */
}
</style>

View File

@ -0,0 +1,659 @@
---
// Terminal.astro
// A component that displays terminal-like interface with animated commands and outputs
interface Command {
prompt: string;
command: string;
output?: string[];
delay?: number;
}
interface Props {
commands: Command[];
title?: string;
theme?: 'dark' | 'light';
interactive?: boolean;
showTitleBar?: boolean;
}
const {
commands,
title = "argobox:~/homelab",
theme = "dark",
interactive = false,
showTitleBar = true
} = Astro.props;
// Make the last command have the typing effect
const lastIndex = commands.length - 1;
// Conditionally add classes based on props
const terminalClasses = `terminal-box ${theme === 'light' ? 'terminal-light' : ''}`;
---
<div class={terminalClasses}>
{showTitleBar && (
<div class="terminal-header">
<div class="terminal-dots">
<div class="terminal-dot terminal-dot-red"></div>
<div class="terminal-dot terminal-dot-yellow"></div>
<div class="terminal-dot terminal-dot-green"></div>
</div>
<div class="terminal-title">{title}</div>
<div class="terminal-actions">
<button class="terminal-button terminal-button-minimize" aria-label="Minimize">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
</button>
<button class="terminal-button terminal-button-maximize" aria-label="Maximize">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 3 21 3 21 9"></polyline>
<polyline points="9 21 3 21 3 15"></polyline>
<line x1="21" y1="3" x2="14" y2="10"></line>
<line x1="3" y1="21" x2="10" y2="14"></line>
</svg>
</button>
</div>
</div>
)}
<div class="terminal-content">
{commands.map((cmd, index) => (
<div class="terminal-block">
<div class="terminal-line">
<span class="terminal-prompt">{cmd.prompt}</span>
<span class={index === lastIndex ? "terminal-command terminal-typing" : "terminal-command"} data-delay={cmd.delay || 50}>
{cmd.command}
</span>
</div>
{cmd.output && cmd.output.length > 0 && (
<div class="terminal-output">
{cmd.output.map((line) => (
<div class="terminal-output-line" set:html={line} />
))}
</div>
)}
</div>
))}
{interactive && (
<div class="terminal-block terminal-interactive">
<div class="terminal-line">
<span class="terminal-prompt">guest@argobox:~$</span>
<input type="text" class="terminal-input" placeholder="Type 'help' for available commands" />
</div>
</div>
)}
<div class="terminal-cursor"></div>
</div>
</div>
<style>
.terminal-box {
width: 100%;
height: 340px;
background: var(--bg-secondary, #0d1529);
border-radius: 10px;
border: 1px solid var(--border-primary, rgba(255, 255, 255, 0.1));
box-shadow: 0 0 30px rgba(6, 182, 212, 0.1);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 0.9rem;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
/* Light theme */
.terminal-light {
background: #f0f4f8;
border-color: #d1dce5;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.07);
color: #1a202c;
}
.terminal-light .terminal-prompt {
color: #2a7ac0;
}
.terminal-light .terminal-command {
color: #1a202c;
}
.terminal-light .terminal-output {
color: #4a5568;
}
.terminal-light .terminal-header {
border-bottom: 1px solid #e2e8f0;
}
.terminal-light .terminal-title {
color: #4a5568;
}
/* Header */
.terminal-header {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
margin-bottom: 0.5rem;
border-bottom: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
height: 40px;
}
.terminal-dots {
display: flex;
gap: 0.5rem;
}
.terminal-dot {
width: 12px;
height: 12px;
border-radius: 50%;
transition: all 0.3s ease;
}
.terminal-dot-red {
background: #ef4444;
}
.terminal-dot-yellow {
background: #eab308;
}
.terminal-dot-green {
background: #22c55e;
}
.terminal-title {
margin-left: auto;
margin-right: auto;
color: var(--text-secondary, #a0aec0);
font-size: 0.8rem;
}
.terminal-actions {
display: flex;
gap: 0.5rem;
}
.terminal-button {
background: transparent;
border: none;
color: var(--text-secondary, #a0aec0);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: 2px;
transition: all 0.2s ease;
}
.terminal-button:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary, #e2e8f0);
}
/* Content */
.terminal-content {
flex: 1;
color: var(--text-secondary, #a0aec0);
overflow-y: auto;
padding: 0.5rem 1.5rem 1.5rem;
opacity: 0.9;
position: relative;
}
.terminal-block {
margin-bottom: 1rem;
}
.terminal-line {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.terminal-prompt {
color: var(--accent-primary, #06b6d4);
margin-right: 0.5rem;
font-weight: bold;
white-space: nowrap;
}
.terminal-command {
color: var(--text-primary, #e2e8f0);
word-break: break-word;
}
.terminal-output {
margin-top: 0.5rem;
margin-bottom: 1rem;
padding-left: 1.25rem;
color: var(--text-secondary, #a0aec0);
font-size: 0.85rem;
line-height: 1.5;
}
.terminal-output-line {
margin-bottom: 0.25rem;
}
/* Highlight syntax in output */
.terminal-output :global(.highlight) {
color: var(--accent-primary, #06b6d4);
}
.terminal-output :global(.success) {
color: #22c55e;
}
.terminal-output :global(.warning) {
color: #eab308;
}
.terminal-output :global(.error) {
color: #ef4444;
}
/* Interactive elements */
.terminal-interactive {
margin-top: 1rem;
}
.terminal-input {
background: transparent;
border: none;
color: var(--text-primary, #e2e8f0);
font-family: var(--font-mono, 'JetBrains Mono', monospace);
font-size: 0.9rem;
flex: 1;
outline: none;
caret-color: transparent; /* Hide default cursor */
}
/* Blinking cursor */
.terminal-cursor {
position: absolute;
display: inline-block;
width: 8px;
height: 16px;
background: var(--accent-primary, #06b6d4);
animation: blink 1s infinite;
bottom: 2rem;
left: calc(1.5rem + 200px); /* Adjustable using JS */
opacity: 0;
}
.terminal-interactive:has(.terminal-input:focus) ~ .terminal-cursor {
opacity: 1;
}
/* Typing effect */
.terminal-typing {
position: relative;
white-space: pre-wrap;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Scrollbar styling */
.terminal-content::-webkit-scrollbar {
width: 6px;
}
.terminal-content::-webkit-scrollbar-track {
background: rgba(15, 23, 42, 0.3);
}
.terminal-content::-webkit-scrollbar-thumb {
background: rgba(226, 232, 240, 0.2);
border-radius: 3px;
}
.terminal-content::-webkit-scrollbar-thumb:hover {
background: rgba(226, 232, 240, 0.3);
}
/* Fixed dot hovered appearance */
.terminal-box:hover .terminal-dot-red {
background: #f87171;
}
.terminal-box:hover .terminal-dot-yellow {
background: #fbbf24;
}
.terminal-box:hover .terminal-dot-green {
background: #34d399;
}
@media (max-width: 768px) {
.terminal-box {
height: 300px;
font-size: 0.8rem;
}
}
</style>
<script>
// Terminal typing effect
document.addEventListener('DOMContentLoaded', function() {
// Typing effect for commands
const typingElements = document.querySelectorAll('.terminal-typing');
typingElements.forEach((typingElement, elementIndex) => {
const text = typingElement.textContent || '';
const delay = parseInt(typingElement.getAttribute('data-delay') || '50', 10);
// Clear the element
typingElement.textContent = '';
let i = 0;
// Random delay before starting to type (sequential if there are multiple)
setTimeout(() => {
function typeWriter() {
if (i < text.length) {
typingElement.textContent += text.charAt(i);
i++;
// Random typing speed for realistic effect
const randomVariation = Math.random() * 30 - 15; // -15 to +15ms variation
const speed = delay + randomVariation;
setTimeout(typeWriter, speed);
} else {
// When done typing, scroll terminal content to bottom
const terminalContent = typingElement.closest('.terminal-content');
if (terminalContent) {
terminalContent.scrollTop = terminalContent.scrollHeight;
}
// Add blinking cursor after the last command
if (elementIndex === typingElements.length - 1) {
const cursor = typingElement.closest('.terminal-box').querySelector('.terminal-cursor');
if (cursor) {
const rect = typingElement.getBoundingClientRect();
const parentRect = typingElement.closest('.terminal-content').getBoundingClientRect();
// Position cursor after the last character
cursor.style.opacity = '1';
cursor.style.left = `${rect.left - parentRect.left + typingElement.offsetWidth}px`;
cursor.style.top = `${rect.top - parentRect.top}px`;
cursor.style.height = `${rect.height}px`;
}
}
}
}
typeWriter();
}, 1000 * elementIndex); // Sequential delay for multiple typing elements
});
// Interactive terminal functionality
const interactiveTerminals = document.querySelectorAll('.terminal-interactive');
interactiveTerminals.forEach(terminal => {
const input = terminal.querySelector('.terminal-input');
const terminalContent = terminal.closest('.terminal-content');
const prompt = terminal.querySelector('.terminal-prompt').textContent;
if (!input || !terminalContent) return;
// Position cursor when input is focused
input.addEventListener('focus', () => {
const cursor = terminal.closest('.terminal-box').querySelector('.terminal-cursor');
if (cursor) {
const rect = input.getBoundingClientRect();
const parentRect = terminalContent.getBoundingClientRect();
cursor.style.left = `${rect.left - parentRect.left + input.value.length * 8}px`;
cursor.style.top = `${rect.top - parentRect.top}px`;
cursor.style.height = `${rect.height}px`;
}
});
// Update cursor position as user types
input.addEventListener('input', () => {
const cursor = terminal.closest('.terminal-box').querySelector('.terminal-cursor');
if (cursor) {
const rect = input.getBoundingClientRect();
const parentRect = terminalContent.getBoundingClientRect();
cursor.style.left = `${rect.left - parentRect.left + 8 * (prompt.length + input.value.length) + 8}px`;
}
});
// Process command on Enter
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const command = input.value.trim();
if (!command) return;
// Create new command block
const commandBlock = document.createElement('div');
commandBlock.className = 'terminal-block';
const commandLine = document.createElement('div');
commandLine.className = 'terminal-line';
const promptSpan = document.createElement('span');
promptSpan.className = 'terminal-prompt';
promptSpan.textContent = prompt;
const commandSpan = document.createElement('span');
commandSpan.className = 'terminal-command';
commandSpan.textContent = command;
commandLine.appendChild(promptSpan);
commandLine.appendChild(commandSpan);
commandBlock.appendChild(commandLine);
// Process command and add output
const output = processCommand(command);
if (output && output.length > 0) {
const outputDiv = document.createElement('div');
outputDiv.className = 'terminal-output';
output.forEach(line => {
const lineDiv = document.createElement('div');
lineDiv.className = 'terminal-output-line';
lineDiv.innerHTML = line;
outputDiv.appendChild(lineDiv);
});
commandBlock.appendChild(outputDiv);
}
// Insert before the interactive block
terminal.parentNode.insertBefore(commandBlock, terminal);
// Clear input
input.value = '';
// Scroll to bottom
terminalContent.scrollTop = terminalContent.scrollHeight;
}
});
// Define available commands and their outputs
function processCommand(cmd) {
const commands = {
'help': [
'<span class="highlight">Available commands:</span>',
' help - Display this help message',
' clear - Clear the terminal',
' ls - List available resources',
' cat [file] - View file contents',
' about - About this site',
' status - Check system status',
' uname -a - Display system information'
],
'clear': [],
'about': [
'<span class="highlight">LaForceIT</span>',
'A tech blog focused on home lab infrastructure, Kubernetes,',
'Docker, and DevOps best practices.',
'',
'Created by Daniel LaForce',
'Type <span class="highlight">\'help\'</span> to see available commands'
],
'uname -a': [
'ArgoBox-Lite 5.15.0-69-generic #76-Ubuntu SMP Fri Mar 17 17:19:29 UTC 2023 x86_64',
'Hardware: ProxmoxVE 8.0.4 | Intel(R) Core(TM) i7-12700K | 64GB RAM'
],
'status': [
'<span class="highlight">System Status:</span>',
'<span class="success">✓</span> ArgoBox: Online',
'<span class="success">✓</span> Kubernetes: Running',
'<span class="success">✓</span> Docker Registry: Active',
'<span class="success">✓</span> Gitea: Online',
'<span class="warning">⚠</span> Monitoring: Degraded - Check Grafana instance'
],
'ls': [
'<span class="highlight">Available resources:</span>',
'kubernetes/ docker/ networking/',
'homelab.md configs.yaml setup-guide.md',
'resources.json projects.md'
]
};
// Check for cat command
if (cmd.startsWith('cat ')) {
const file = cmd.split(' ')[1];
const fileContents = {
'homelab.md': [
'<span class="highlight">## HomeLab Setup Guide</span>',
'This document outlines my personal home lab setup,',
'including hardware specifications, network configuration,',
'and installed services.',
'',
'See the full guide at: /homelab'
],
'configs.yaml': [
'apiVersion: v1',
'kind: ConfigMap',
'metadata:',
' name: argobox-config',
' namespace: default',
'data:',
' POSTGRES_HOST: "db.local"',
' REDIS_HOST: "cache.local"',
' ...'
],
'setup-guide.md': [
'<span class="highlight">## Quick Start Guide</span>',
'1. Install Proxmox on bare metal hardware',
'2. Deploy K3s cluster using Ansible playbooks',
'3. Configure storage using Longhorn',
'4. Deploy ArgoCD for GitOps workflow',
'...'
],
'resources.json': [
'{',
' "cpu": "12 cores",',
' "memory": "64GB",',
' "storage": "8TB",',
' "network": "10Gbit"',
'}'
],
'projects.md': [
'<span class="highlight">## Current Projects</span>',
'- <span class="success">ArgoBox</span>: Self-hosted deployment platform',
'- <span class="success">K8s Monitor</span>: Custom Kubernetes dashboard',
'- <span class="warning">Media Server</span>: In progress',
'- <span class="highlight">See all projects at:</span> /projects'
]
};
if (fileContents[file]) {
return fileContents[file];
} else {
return [`<span class="error">Error: File '${file}' not found.</span>`];
}
}
// Handle unknown commands
if (!commands[cmd]) {
return [`<span class="error">Command not found: ${cmd}</span>`, 'Type <span class="highlight">\'help\'</span> to see available commands'];
}
// Handle clear command
if (cmd === 'clear') {
// Remove all blocks except the interactive one
const blocks = terminalContent.querySelectorAll('.terminal-block:not(.terminal-interactive)');
blocks.forEach(block => block.remove());
return [];
}
return commands[cmd];
}
});
// Button interactions
const minButtons = document.querySelectorAll('.terminal-button-minimize');
const maxButtons = document.querySelectorAll('.terminal-button-maximize');
minButtons.forEach(button => {
button.addEventListener('click', () => {
const terminalBox = button.closest('.terminal-box');
terminalBox.classList.toggle('minimized');
if (terminalBox.classList.contains('minimized')) {
const content = terminalBox.querySelector('.terminal-content');
terminalBox.dataset.prevHeight = terminalBox.style.height;
terminalBox.style.height = '40px';
content.style.display = 'none';
} else {
const content = terminalBox.querySelector('.terminal-content');
terminalBox.style.height = terminalBox.dataset.prevHeight || '340px';
content.style.display = 'block';
}
});
});
maxButtons.forEach(button => {
button.addEventListener('click', () => {
const terminalBox = button.closest('.terminal-box');
terminalBox.classList.toggle('maximized');
if (terminalBox.classList.contains('maximized')) {
const content = terminalBox.querySelector('.terminal-content');
terminalBox.dataset.prevHeight = terminalBox.style.height;
terminalBox.dataset.prevWidth = terminalBox.style.width;
terminalBox.dataset.prevPosition = terminalBox.style.position;
terminalBox.style.position = 'fixed';
terminalBox.style.top = '0';
terminalBox.style.left = '0';
terminalBox.style.width = '100%';
terminalBox.style.height = '100%';
terminalBox.style.zIndex = '9999';
terminalBox.style.borderRadius = '0';
} else {
const content = terminalBox.querySelector('.terminal-content');
terminalBox.style.position = terminalBox.dataset.prevPosition || 'relative';
terminalBox.style.width = terminalBox.dataset.prevWidth || '100%';
terminalBox.style.height = terminalBox.dataset.prevHeight || '340px';
terminalBox.style.zIndex = 'auto';
terminalBox.style.borderRadius = '10px';
terminalBox.style.top = 'auto';
terminalBox.style.left = 'auto';
}
});
});
});
</script>

81
src/content/config.ts Normal file
View File

@ -0,0 +1,81 @@
import { defineCollection, z } from 'astro:content';
// Define custom date validator that handles multiple formats
const customDateParser = (dateString: string | Date | null | undefined) => {
// Handle null/undefined
if (dateString === null || dateString === undefined) {
return new Date();
}
// If date is already a Date object, return it
if (dateString instanceof Date) {
return dateString;
}
// Try to parse the date as is
let date = new Date(dateString);
// For format like "Jul 22 2023"
if (isNaN(date.getTime()) && typeof dateString === 'string') {
try {
// Try various formats
if (dateString.match(/^\d{2}\/\d{2}\/\d{4}$/)) {
const [month, day, year] = dateString.split('/').map(Number);
date = new Date(year, month - 1, day);
}
} catch (e) {
// Default to current date if all parsing fails
date = new Date();
}
}
return date;
};
// Define the base schema for all content
const baseSchema = z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.union([z.string(), z.date(), z.null()]).optional().default(() => new Date()).transform(customDateParser),
updatedDate: z.union([z.string(), z.date(), z.null()]).optional().transform(val => val ? customDateParser(val) : undefined),
heroImage: z.string().optional().nullable(),
category: z.string().optional().default('Uncategorized'),
tags: z.union([z.array(z.string()), z.null()]).optional().default([]),
draft: z.boolean().optional().default(false),
readTime: z.union([z.string(), z.number()]).optional(),
image: z.string().optional(),
excerpt: z.string().optional(),
author: z.string().optional(),
github: z.string().optional(),
live: z.string().optional(),
technologies: z.array(z.string()).optional(),
}).passthrough(); // Allow any other frontmatter properties
// Define collections using the same base schema
const postsCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
const configurationsCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
const projectsCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
const externalPostsCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
// Export the collections
export const collections = {
'posts': postsCollection,
'configurations': configurationsCollection,
'projects': projectsCollection,
'external-posts': externalPostsCollection,
};

79
src/content/config.ts.bak Normal file
View File

@ -0,0 +1,79 @@
import { defineCollection, z } from 'astro:content';
// Define custom date validator that handles mm/dd/yyyy format
const customDateParser = (dateString: string | Date) => {
// If date is already a Date object, return it
if (dateString instanceof Date) {
return dateString;
}
// Try to parse the date as is
let date = new Date(dateString);
// Check if it's in mm/dd/yyyy format (like 04/19/2024)
if (isNaN(date.getTime()) || typeof dateString !== 'string') {
return new Date(0); // Return a default date
}
// For mm/dd/yyyy format, extra handling
if (dateString.match(/^\d{2}\/\d{2}\/\d{4}$/)) {
const [month, day, year] = dateString.split('/').map(Number);
date = new Date(year, month - 1, day); // Month is 0-indexed in JS
}
return date;
};
// Define the base schema for all content
const baseSchema = z.object({
title: z.string(),
description: z.string().optional(),
pubDate: z.union([z.string(), z.date()]).transform(customDateParser),
updatedDate: z.union([z.string(), z.date()]).optional().transform(val => val ? customDateParser(val) : undefined),
heroImage: z.string().optional(),
category: z.string().optional().default('Uncategorized'),
tags: z.array(z.string()).default([]),
draft: z.boolean().optional().default(false),
readTime: z.string().optional(),
image: z.string().optional(),
excerpt: z.string().optional(),
author: z.string().optional(),
github: z.string().optional(),
live: z.string().optional(),
technologies: z.array(z.string()).optional(),
});
// Define collections using the same base schema
const postsCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
const configurationsCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
const projectsCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
const externalPostsCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
const blogCollection = defineCollection({
type: 'content',
schema: baseSchema,
});
// Export the collections
export const collections = {
'posts': postsCollection,
'configurations': configurationsCollection,
'projects': projectsCollection,
'external-posts': externalPostsCollection,
'blog': blogCollection,
};

View File

@ -1,3 +1,17 @@
---
title: Git Symbolic Links Setup for Blog Content
pubDate: 2023-11-01
description: A comprehensive guide to setting up Git with symbolic links for efficient content management between Obsidian and your blog codebase
author: LaForce IT
heroImage: /images/git-symlinks-hero.jpg
category: Development
tags:
- git
- obsidian
- workflow
- automation
---
# Git Symbolic Links Setup for Blog Content # Git Symbolic Links Setup for Blog Content
## Overview ## Overview

View File

@ -1,12 +0,0 @@
---
title: "External Posts Collection"
description: "A placeholder document for the external-posts collection"
pubDate: 2025-04-18
category: "External"
tags: ["placeholder"]
draft: false
---
# External Posts Collection
This is a placeholder file for the external-posts collection.

0
src/content/posts/README.md Executable file → Normal file
View File

2
src/content/posts/Test.md Executable file → Normal file
View File

@ -2,7 +2,7 @@
title: Secure Remote Access with Cloudflare Tunnels title: Secure Remote Access with Cloudflare Tunnels
description: How to set up Cloudflare Tunnels for secure remote access to your home lab services description: How to set up Cloudflare Tunnels for secure remote access to your home lab services
pubDate: Jul 22 2023 pubDate: Jul 22 2023
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
category: networking category: networking
tags: tags:
- cloudflare - cloudflare

2
src/content/posts/cloudflare-tunnel-setup.md Executable file → Normal file
View File

@ -2,7 +2,7 @@
title: Secure Remote Access with Cloudflare Tunnels title: Secure Remote Access with Cloudflare Tunnels
description: How to set up Cloudflare Tunnels for secure remote access to your home lab services description: How to set up Cloudflare Tunnels for secure remote access to your home lab services
pubDate: 2025-04-19 pubDate: 2025-04-19
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
category: networking category: networking
tags: tags:
- cloudflare - cloudflare

2
src/content/posts/filebrowser-setup.md Executable file → Normal file
View File

@ -10,7 +10,7 @@ tags:
- kubernetes - kubernetes
- docker - docker
- file-management - file-management
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
--- ---
I've said it before, and I'll say it again - the journey to a well-organized digital life begins with proper file management. If you're like me, you've got files scattered across multiple devices, cloud services, and servers. What if I told you there's a lightweight, sleek solution that puts you back in control without relying on third-party services? I've said it before, and I'll say it again - the journey to a well-organized digital life begins with proper file management. If you're like me, you've got files scattered across multiple devices, cloud services, and servers. What if I told you there's a lightweight, sleek solution that puts you back in control without relying on third-party services?

2
src/content/posts/gitea-self-hosted-git.md Executable file → Normal file
View File

@ -10,7 +10,7 @@ tags:
- self-hosted - self-hosted
- devops - devops
- kubernetes - kubernetes
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
--- ---
If you're a developer like me who values ownership and privacy, you've probably wondered if there's a way to get the convenience of GitHub or GitLab without handing over your code to a third party. Enter Gitea - a painless, self-hosted Git service written in Go that I've been using for my personal projects for the past year. If you're a developer like me who values ownership and privacy, you've probably wondered if there's a way to get the convenience of GitHub or GitLab without handing over your code to a third party. Enter Gitea - a painless, self-hosted Git service written in Go that I've been using for my personal projects for the past year.

2
src/content/posts/gitops-with-flux-cd.md Executable file → Normal file
View File

@ -2,7 +2,7 @@
title: GitOps with Flux CD title: GitOps with Flux CD
description: Implementing GitOps workflows on Kubernetes using Flux CD description: Implementing GitOps workflows on Kubernetes using Flux CD
pubDate: 2025-04-19 pubDate: 2025-04-19
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
category: devops category: devops
tags: tags:
- kubernetes - kubernetes

93
src/content/posts/k3s-cluster.md Executable file → Normal file
View File

@ -1,18 +1,87 @@
--- ---
title: Setting Up a K3s Kubernetes Cluster title: "Setting Up a K3s Kubernetes Cluster"
pubDate: 2025-04-19 description: "A comprehensive guide to setting up a K3s cluster for your home lab or edge environment, with high availability and persistent storage."
description: A comprehensive guide to setting up and configuring a lightweight K3s Kubernetes cluster for your home lab pubDate: "2023-11-15"
category: Infrastructure heroImage: "/blog/images/posts/k3installation.png"
tags: category: "Kubernetes"
- kubernetes tags: ["kubernetes", "k3s", "homelab", "infrastructure"]
- k3s
- infrastructure
- homelab
- containers
draft: false draft: false
heroImage:
--- ---
# Setting Up a K3s Kubernetes Cluster # Setting Up a K3s Kubernetes Cluster
Coming soon... K3s is a lightweight, certified Kubernetes distribution designed for resource-constrained environments like edge devices, IoT, and home labs. This guide will walk you through setting up a production-ready K3s cluster.
## Prerequisites
- At least two machines (physical or virtual) for high availability
- Ubuntu 20.04 LTS or newer
- At least 2GB RAM per node
- 20GB+ storage per node
- Network connectivity between all nodes
## Installing the Server Node
First, let's install the primary server node:
```bash
curl -sfL https://get.k3s.io | sh -s - server \
--cluster-init \
--tls-san=server-ip-or-hostname \
--disable traefik \
--disable servicelb
```
This initializes the cluster with:
- HA enabled with `--cluster-init`
- Custom TLS SAN for API server
- Disabled default traefik ingress (we'll use Nginx)
- Disabled default ServiceLB (we'll use MetalLB)
## Installing Agent Nodes
On each worker node, run:
```bash
curl -sfL https://get.k3s.io | K3S_URL=https://server-ip:6443 K3S_TOKEN=node-token sh -
```
Replace `server-ip` with your server's IP and get the token from `/var/lib/rancher/k3s/server/node-token` on the server.
## Adding High Availability
For HA, add additional server nodes:
```bash
curl -sfL https://get.k3s.io | sh -s - server \
--server https://first-server-ip:6443 \
--token node-token \
--tls-san=this-server-ip \
--disable traefik \
--disable servicelb
```
## Setting Up Persistent Storage
We'll use Longhorn for distributed storage:
```bash
kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/master/deploy/longhorn.yaml
```
Longhorn provides replicated block storage across your nodes for high availability.
## Next Steps
After setting up your cluster, you might want to:
1. Install a proper ingress controller (Nginx, Traefik)
2. Set up a load balancer (MetalLB)
3. Configure monitoring with Prometheus and Grafana
4. Implement GitOps with Flux or ArgoCD
Stay tuned for detailed guides on each of these topics!
---
This guide provides a starting point for your journey with K3s Kubernetes. In future posts, we'll dive deeper into advanced configurations.

2
src/content/posts/k3s-installation-guide.md Executable file → Normal file
View File

@ -2,7 +2,7 @@
title: K3s Installation Guide title: K3s Installation Guide
description: A comprehensive guide to installing and configuring K3s for your home lab description: A comprehensive guide to installing and configuring K3s for your home lab
pubDate: 2025-04-19 pubDate: 2025-04-19
heroImage: /blog/images/posts/k3installation.png heroImage: /images/posts/k3installation.png
category: kubernetes category: kubernetes
tags: tags:
- kubernetes - kubernetes

359
src/content/posts/prometheus-monitoring.md Executable file → Normal file
View File

@ -1,142 +1,64 @@
--- ---
title: Setting Up Prometheus Monitoring in Kubernetes title: "Monitoring Your Kubernetes Cluster with Prometheus and Grafana"
description: A comprehensive guide to implementing Prometheus monitoring in your Kubernetes cluster description: "A comprehensive guide to setting up a robust monitoring solution for your Kubernetes cluster using Prometheus and Grafana."
pubDate: 2025-04-19 pubDate: "2023-09-25"
heroImage: /blog/images/posts/prometheusk8.png heroImage: "/blog/images/posts/prometheus-dashboard.svg"
category: devops category: "Monitoring"
tags: tags: ["kubernetes", "prometheus", "grafana", "monitoring", "observability"]
- kubernetes draft: false
- monitoring
- prometheus
- grafana
- observability
readTime: 9 min read
--- ---
# Setting Up Prometheus Monitoring in Kubernetes # Monitoring Your Kubernetes Cluster with Prometheus and Grafana
Effective monitoring is crucial for maintaining a healthy Kubernetes environment. Prometheus has become the de facto standard for metrics collection and alerting in cloud-native environments. This guide will walk you through setting up a complete Prometheus monitoring stack in your Kubernetes cluster. In today's complex Kubernetes environments, having a robust monitoring solution is not just nice to have—it's essential. This guide will walk you through setting up Prometheus and Grafana to monitor your K3s or any other Kubernetes cluster.
## Why Prometheus? ## Why Prometheus and Grafana?
Prometheus offers several advantages for Kubernetes monitoring: - **Prometheus**: An open-source systems monitoring and alerting toolkit that collects and stores metrics as time series data
- **Grafana**: A multi-platform open-source analytics and interactive visualization web application that provides charts, graphs, and alerts when connected to supported data sources
- **Pull-based architecture**: Simplifies configuration and security Together, they form a powerful monitoring stack that provides insights into your cluster's health and performance.
- **Powerful query language (PromQL)**: For flexible data analysis
- **Service discovery**: Automatically finds targets in dynamic environments
- **Rich ecosystem**: Wide range of exporters and integrations
- **CNCF graduated project**: Strong community and vendor support
## Components of the Monitoring Stack
We'll set up a complete monitoring stack consisting of:
1. **Prometheus**: Core metrics collection and storage
2. **Alertmanager**: Handles alerts and notifications
3. **Grafana**: Visualization and dashboards
4. **Node Exporter**: Collects host-level metrics
5. **kube-state-metrics**: Collects Kubernetes state metrics
6. **Prometheus Operator**: Simplifies Prometheus management in Kubernetes
## Prerequisites ## Prerequisites
- A running Kubernetes cluster (K3s, EKS, GKE, etc.) Before we begin, ensure you have:
- kubectl configured to access your cluster
- A running Kubernetes cluster (this guide uses K3s)
- `kubectl` configured to communicate with your cluster
- Helm 3 installed - Helm 3 installed
## Installation Using Helm ## Installation using Helm
The easiest way to deploy Prometheus is using the kube-prometheus-stack Helm chart, which includes all the components mentioned above. The easiest way to install Prometheus and Grafana is using the kube-prometheus-stack Helm chart, which includes:
### 1. Add the Prometheus Community Helm Repository - Prometheus Operator
- Prometheus instance
- Alertmanager
- Grafana
- Node Exporter
- Kube State Metrics
Let's create a namespace and install the stack:
```bash ```bash
# Create a dedicated namespace
kubectl create namespace monitoring
# Add the Prometheus community Helm repository
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update helm repo update
```
### 2. Create a Namespace for Monitoring # Install the kube-prometheus-stack
```bash
kubectl create namespace monitoring
```
### 3. Configure Values
Create a `values.yaml` file with your custom configuration:
```yaml
prometheus:
prometheusSpec:
retention: 15d
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 2Gi
cpu: 500m
storageSpec:
volumeClaimTemplate:
spec:
storageClassName: standard
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
alertmanager:
alertmanagerSpec:
storage:
volumeClaimTemplate:
spec:
storageClassName: standard
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 10Gi
grafana:
persistence:
enabled: true
storageClassName: standard
size: 10Gi
adminPassword: "prom-operator" # Change this!
nodeExporter:
enabled: true
kubeStateMetrics:
enabled: true
```
### 4. Install the Helm Chart
```bash
helm install prometheus prometheus-community/kube-prometheus-stack \ helm install prometheus prometheus-community/kube-prometheus-stack \
--namespace monitoring \ --namespace monitoring \
--values values.yaml --set grafana.adminPassword=your-strong-password
``` ```
### 5. Verify the Installation Replace `your-strong-password` with a secure password for the Grafana admin user.
Check that all the pods are running: ## Accessing the Dashboards
```bash By default, the services are not exposed outside the cluster. To access them, you can use port-forwarding:
kubectl get pods -n monitoring
```
## Accessing the UIs
By default, the components don't have external access. You can use port-forwarding to access them:
### Prometheus UI
```bash
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090
```
Then access Prometheus at http://localhost:9090
### Grafana ### Grafana
@ -144,40 +66,31 @@ Then access Prometheus at http://localhost:9090
kubectl port-forward -n monitoring svc/prometheus-grafana 3000:80 kubectl port-forward -n monitoring svc/prometheus-grafana 3000:80
``` ```
Then access Grafana at http://localhost:3000 (default credentials: admin/prom-operator) Then access Grafana at http://localhost:3000 with username `admin` and the password you specified during installation.
### Alertmanager ### Prometheus
```bash ```bash
kubectl port-forward -n monitoring svc/prometheus-alertmanager 9093:9093 kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090
``` ```
Then access Alertmanager at http://localhost:9093 Access the Prometheus UI at http://localhost:9090.
## For Production: Exposing Services ## Setting Up Ingress (Optional)
For production environments, you'll want to set up proper ingress. Here's an example using a basic Ingress resource: For production environments, you'll want to set up proper ingress. Here's an example using Nginx ingress:
```yaml ```yaml
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
name: prometheus-ingress name: grafana-ingress
namespace: monitoring namespace: monitoring
annotations: annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/ssl-redirect: "true" nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec: spec:
rules: rules:
- host: prometheus.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: prometheus-operated
port:
number: 9090
- host: grafana.example.com - host: grafana.example.com
http: http:
paths: paths:
@ -188,102 +101,126 @@ spec:
name: prometheus-grafana name: prometheus-grafana
port: port:
number: 80 number: 80
- host: alertmanager.example.com tls:
http: - hosts:
paths: - grafana.example.com
- path: / secretName: grafana-tls
pathType: Prefix
backend:
service:
name: prometheus-alertmanager
port:
number: 9093
``` ```
## Configuring Alerting Apply this with `kubectl apply -f ingress.yaml` after replacing `grafana.example.com` with your domain.
### 1. Set Up Alert Rules ## Important Dashboards for Kubernetes
Alert rules can be created using the PrometheusRule custom resource: Grafana comes with several pre-installed dashboards, but here are some essential ones you should import:
1. **Kubernetes Cluster Overview** (ID: 10856)
2. **Node Exporter Full** (ID: 1860)
3. **Kubernetes Resource Requests** (ID: 13770)
To import a dashboard:
1. Go to Grafana UI
2. Click on "+" icon in the sidebar
3. Select "Import"
4. Enter the dashboard ID
5. Click "Load"
6. Select the Prometheus data source
7. Click "Import"
## Setting Up Alerts
Let's set up a basic alert for node CPU usage:
1. In Grafana, go to Alerting > Alert Rules
2. Click "New Alert Rule"
3. Configure the query: `instance:node_cpu_utilisation:rate5m > 0.8`
4. Set the condition to: `IS ABOVE 0.8`
5. Set evaluation interval: `1m`
6. Set "For": `5m` (alert will fire if condition is true for 5 minutes)
7. Add labels and annotations as needed
8. Save the rule
## Best Practices
1. **Resource Limits**: Set appropriate resource requests and limits for Prometheus and Grafana
2. **Retention Period**: Configure the retention period based on your storage capacity
3. **Persistent Storage**: Use persistent volumes for Prometheus data
4. **Federation**: For large clusters, consider Prometheus federation
5. **Custom Metrics**: Set up custom metrics for your applications using client libraries
## Advanced Configuration
For a production environment, you'll want to customize the Helm values. Create a `values.yaml` file:
```yaml ```yaml
apiVersion: monitoring.coreos.com/v1 prometheus:
kind: PrometheusRule prometheusSpec:
metadata: retention: 15d
name: node-alerts resources:
namespace: monitoring requests:
labels: memory: 2Gi
release: prometheus cpu: 500m
spec: limits:
groups: memory: 4Gi
- name: node.rules cpu: 1000m
rules: storageSpec:
- alert: HighNodeCPU volumeClaimTemplate:
expr: instance:node_cpu_utilisation:rate1m > 0.8 spec:
for: 5m storageClassName: standard
labels: accessModes: ["ReadWriteOnce"]
severity: warning resources:
annotations: requests:
summary: "High CPU usage on {{ $labels.instance }}" storage: 50Gi
description: "CPU usage is above 80% for 5 minutes on node {{ $labels.instance }}"
grafana:
persistence:
enabled: true
size: 10Gi
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 512Mi
cpu: 200m
``` ```
### 2. Configure Alert Receivers Then update your Helm release:
Configure Alertmanager to send notifications by creating a Secret with your configuration: ```bash
helm upgrade prometheus prometheus-community/kube-prometheus-stack \
```yaml --namespace monitoring \
apiVersion: v1 -f values.yaml
kind: Secret
metadata:
name: alertmanager-prometheus-alertmanager
namespace: monitoring
stringData:
alertmanager.yaml: |
global:
resolve_timeout: 5m
slack_api_url: 'https://hooks.slack.com/services/YOUR/SLACK/WEBHOOK'
route:
group_by: ['job', 'alertname', 'namespace']
group_wait: 30s
group_interval: 5m
repeat_interval: 12h
receiver: 'slack-notifications'
routes:
- receiver: 'slack-notifications'
matchers:
- severity =~ "warning|critical"
receivers:
- name: 'slack-notifications'
slack_configs:
- channel: '#alerts'
send_resolved: true
title: '{{ template "slack.default.title" . }}'
text: '{{ template "slack.default.text" . }}'
type: Opaque
``` ```
## Custom Dashboards
Grafana comes pre-configured with several useful dashboards, but you can import more from [Grafana.com](https://grafana.com/grafana/dashboards/).
Some recommended dashboard IDs to import:
- 1860: Node Exporter Full
- 12740: Kubernetes Monitoring
- 13332: Prometheus Stats
## Troubleshooting ## Troubleshooting
### Common Issues ### Common Issues
1. **Insufficient Resources**: Prometheus can be resource-intensive. Adjust resource limits if pods are being OOMKilled. 1. **Insufficient Resources**: If pods are crashing, check if they have enough resources allocated
2. **Storage Issues**: Ensure your storage class supports the access modes you've configured. 2. **Connectivity Issues**: Ensure services can communicate with each other
3. **ServiceMonitor not working**: Check that the label selectors match your services. 3. **Data Retention**: If Prometheus is losing data, check the storage configuration
4. **Target Scraping**: If metrics aren't appearing, check Prometheus targets status
### Useful Commands
```bash
# Check pod status
kubectl get pods -n monitoring
# Check Prometheus targets
kubectl port-forward -n monitoring svc/prometheus-operated 9090:9090
# Then visit http://localhost:9090/targets
# View Prometheus logs
kubectl logs -n monitoring deploy/prometheus-operator
# View Grafana logs
kubectl logs -n monitoring deploy/prometheus-grafana
```
## Conclusion ## Conclusion
You now have a fully functional Prometheus monitoring stack for your Kubernetes cluster. This setup provides comprehensive metrics collection, visualization, and alerting capabilities essential for maintaining a healthy and performant cluster. You now have a robust monitoring solution for your Kubernetes cluster. With Prometheus collecting metrics and Grafana visualizing them, you'll have deep insights into your cluster's performance and health.
In future articles, we'll explore advanced topics like custom exporters, recording rules for performance, and integrating with other observability tools like Loki for logs and Tempo for traces. In future articles, we'll explore more advanced topics like custom exporters, alert integrations, and high availability setups for your monitoring stack.

2
src/content/posts/proxmox-setup-guide.md Executable file → Normal file
View File

@ -2,7 +2,7 @@
title: Complete Proxmox VE Setup Guide title: Complete Proxmox VE Setup Guide
description: A step-by-step guide to setting up Proxmox VE for your home lab virtualization needs description: A step-by-step guide to setting up Proxmox VE for your home lab virtualization needs
pubDate: 2025-04-19 pubDate: 2025-04-19
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
category: infrastructure category: infrastructure
tags: tags:
- proxmox - proxmox

View File

@ -1,23 +0,0 @@
#!/bin/bash
# Script to push blog content to Gitea with proper symlink handling
# This ensures actual content is pushed, not just symlinks
# Navigate to the blog repository
cd ~/Projects/laforceit-blog
# Configure git to handle symlinks correctly
git config --local core.symlinks true
# Add all changes, dereferencing symlinks to include the actual content
git add -A --dereference
# Get commit message from argument or use default
COMMIT_MSG=${1:-"Update blog content"}
# Commit changes
git commit -m "$COMMIT_MSG"
# Push to the repository
git push origin main

2
src/content/posts/quartz-digital-garden.md Executable file → Normal file
View File

@ -10,7 +10,7 @@ tags:
- digital-garden - digital-garden
- knowledge-management - knowledge-management
- astro - astro
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
--- ---
I've been taking digital notes for decades now. From simple `.txt` files to OneNote, Evernote, Notion, and now Obsidian. But for years, I've been wrestling with a question: how do I share my knowledge with others in a way that preserves the connections between ideas? I've been taking digital notes for decades now. From simple `.txt` files to OneNote, Evernote, Notion, and now Obsidian. But for years, I've been wrestling with a question: how do I share my knowledge with others in a way that preserves the connections between ideas?

4
src/content/posts/rancher-kubernetes-management.md Executable file → Normal file
View File

@ -10,7 +10,7 @@ tags:
- k3s - k3s
- devops - devops
- containers - containers
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
--- ---
I've been running Kubernetes at home for years now, and I've tried just about every management tool out there. From kubectl and a bunch of YAML files to various dashboards and UIs, I've experimented with it all. But the one tool that's been a constant in my home lab journey is [Rancher](https://rancher.com/) - a complete container management platform that makes Kubernetes management almost... dare I say it... enjoyable? I've been running Kubernetes at home for years now, and I've tried just about every management tool out there. From kubectl and a bunch of YAML files to various dashboards and UIs, I've experimented with it all. But the one tool that's been a constant in my home lab journey is [Rancher](https://rancher.com/) - a complete container management platform that makes Kubernetes management almost... dare I say it... enjoyable?
@ -127,7 +127,7 @@ In minutes, you'll have a full monitoring stack with pre-configured dashboards f
Here's what my Grafana dashboard looks like for my home K8s cluster: Here's what my Grafana dashboard looks like for my home K8s cluster:
![Rancher Grafana Dashboard](/blog/images/services/rancher-monitoring.jpg) ![Rancher Grafana Dashboard](/images/services/rancher-monitoring.jpg)
## Creating Deployments Through the UI ## Creating Deployments Through the UI

63
src/content/posts/starting-my-digital-garden.md Executable file → Normal file
View File

@ -1,40 +1,57 @@
--- ---
title: Starting My Digital Garden title: "Starting My Digital Garden"
pubDate: 2025-04-19 description: "How and why I'm approaching this blog as a digital garden rather than a traditional chronological blog."
tags: pubDate: "2023-10-05"
- blog heroImage: "/blog/images/placeholders/default.jpg"
- meta category: "Meta"
- digital-garden tags: ["digital-garden", "knowledge-management", "learning-in-public"]
draft: false draft: false
heroImage: /blog/images/posts/prometheusk8.png
--- ---
# Starting My Digital Garden # Starting My Digital Garden
## Introduction Instead of creating yet another chronological blog, I've decided to structure this site as a "digital garden" - a place where ideas grow and evolve over time.
Today I'm launching my public digital garden - a space where I'll share my thoughts, ideas, and projects with the world. ## What is a Digital Garden?
## What to Expect A digital garden is a collection of notes, articles, and resources that aren't organized strictly by date, but rather by topic and interconnectedness. Unlike traditional blogs where posts are published once and rarely updated, digital gardens embrace the idea of continuous growth and refinement.
This site contains two main sections: Key characteristics of digital gardens include:
1. **Journal** - Daily notes and thoughts, more raw and in-the-moment - **Living documents**: Content is regularly revisited and updated as my understanding evolves
2. **Blog** - More structured and polished articles on various topics - **Non-linear**: Ideas are interconnected through links rather than presented in strict chronology
- **Varying levels of completion**: Some notes are polished essays, others are rough sketches of ideas
I'll be using my daily journal to capture ideas as they happen, and some of these will evolve into more detailed blog posts over time. - **Learning in public**: Sharing my learning process, not just the finished product
## Why a Digital Garden? ## Why a Digital Garden?
Unlike traditional blogs that are often published, then forgotten, a digital garden is meant to grow and evolve over time. I'll be revisiting and updating content as my thoughts and understanding develop. The digital garden approach aligns perfectly with how I learn about and work with technology:
## Topics I'll Cover 1. **Technology evolves**: My guides and tutorials will evolve alongside the technologies they describe
2. **Connection between concepts**: Infrastructure, automation, and deployment are deeply interconnected
3. **Continuous improvement**: I can revisit and enhance articles as I discover better approaches
4. **Lower barrier to publishing**: I can share work-in-progress ideas without waiting for "perfection"
- Technology projects I'm working on ## How This Works
- Learning notes and discoveries
- Workflow and productivity systems
- Occasional personal reflections
## Stay Connected On this site, you'll find:
Feel free to check back regularly to see what's growing in this garden. The journal section will be updated most frequently, while blog posts will appear when ideas have had time to mature. - **Posts**: Longer-form articles that explain concepts, provide tutorials, or share insights
- **Configurations**: Specific configuration guides and setup instructions
- **Projects**: Documentation of my homelab and technical projects
Content will be interconnected through links and the visualization graph on the homepage. Every post has a "Last Updated" date so you can see how recently the information was reviewed.
## A Note on "Maturity"
Not all content in a digital garden has the same level of completeness. I'll be using these general states:
- **Seedlings**: Early ideas, rough notes, or work-in-progress
- **Budding**: Structured content with the main points established but still developing
- **Evergreen**: Well-developed, comprehensive resources that are regularly maintained
I look forward to growing this digital garden over time and hope you find the content useful on your own technical journey!
---
*This introduction to my digital garden concept was last updated on October 5, 2023.*

2
src/content/posts/test-post.md Executable file → Normal file
View File

@ -6,7 +6,7 @@ category: Test
tags: tags:
- test - test
draft: true draft: true
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
--- ---
# Test Post # Test Post

2
src/content/posts/vscode-server-remote-development.md Executable file → Normal file
View File

@ -10,7 +10,7 @@ tags:
- self-hosted - self-hosted
- coding - coding
- homelab - homelab
heroImage: /blog/images/posts/prometheusk8.png heroImage: /images/posts/prometheusk8.png
--- ---
If you're like me, you probably find yourself coding on multiple devices - maybe a desktop at home, a laptop when traveling, or even occasionally on a tablet. For years, keeping development environments in sync was a pain point. Enter [VS Code Server](https://code.visualstudio.com/docs/remote/vscode-server), the solution that has completely transformed my development workflow. If you're like me, you probably find yourself coding on multiple devices - maybe a desktop at home, a laptop when traveling, or even occasionally on a tablet. For years, keeping development environments in sync was a pain point. Enter [VS Code Server](https://code.visualstudio.com/docs/remote/vscode-server), the solution that has completely transformed my development workflow.

53
src/content/projects/argobox.md Executable file → Normal file
View File

@ -0,0 +1,53 @@
---
title: ArgoBox
pubDate: 2023-10-15
description: A homelab server setup with Docker, Kubernetes, and various self-hosted services
author: LaForce IT
heroImage: /images/argobox-hero.jpg
category: Infrastructure
technologies:
- Docker
- Kubernetes
- Self-hosted
- Proxmox
- K3s
github: https://github.com/KeyArgo/homelab-infrastructure
draft: false
---
# ArgoBox
ArgoBox is a comprehensive homelab infrastructure project that combines virtualization, containerization, and automation to create a robust home server environment.
## Overview
This project serves as the foundation for all my self-hosted services, including:
- K3s Kubernetes cluster
- GitOps with Flux CD
- Monitoring with Prometheus and Grafana
- Home automation tools
- Media services
- Development environment
## Technical Stack
- **Hypervisor**: Proxmox VE
- **Container Orchestration**: K3s Kubernetes
- **Configuration Management**: Ansible
- **GitOps**: Flux CD
- **Storage**: Longhorn, NFS
- **Networking**: MetalLB, Cloudflare Tunnels
- **Monitoring**: Prometheus, Grafana, Loki
## Project Goals
- Create a reliable, self-healing infrastructure
- Implement GitOps practices for declarative configuration
- Enable easy deployment of new services
- Provide comprehensive monitoring and alerting
- Document the setup for reproducibility
## Current Status
The project is continuously evolving with new services and improvements being added regularly. Check the GitHub repository for the latest updates and documentation.

0
src/content/projects/placeholder.md Executable file → Normal file
View File

1
src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View File

@ -0,0 +1,355 @@
---
// BaseLayout.astro
// Primary layout component that provides the fundamental structure and styles for the site
interface Props {
title: string;
description?: string;
image?: string;
}
const {
title,
description = "LaForceIT Blog - Home Lab & DevOps Insights",
image = "/images/og-image.jpg" // Make sure this image exists in public/images/
} = Astro.props;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<!-- OpenGraph/Social Media Meta Tags -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={Astro.site ? new URL(image, Astro.site).href : image} /> {/* Use absolute URL */}
<meta property="og:url" content={Astro.url} />
<meta property="og:type" content="website" />
<!-- Twitter Card data -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content={title}>
<meta name="twitter:description" content={description}>
<meta name="twitter:image" content={Astro.site ? new URL(image, Astro.site).href : image}> {/* Use absolute URL */}
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@100;300;400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Schema.org markup for Google -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "LaForceIT Blog",
"url": Astro.site ? new URL(Astro.url.pathname, Astro.site).href : Astro.url.href, // Use absolute URL
"description": description,
"author": {
"@type": "Person",
"name": "Daniel LaForce"
}
}
</script>
<!-- Global CSS Variables & Base Styles -->
<style is:global>
:root {
/* Primary Colors */
--bg-primary: #0f1219;
--bg-secondary: #161a24;
--bg-tertiary: #1e2330;
--bg-code: #1a1e2a;
--text-primary: #e2e8f0;
--text-secondary: #a0aec0;
--text-tertiary: #718096;
/* Accent Colors */
--accent-primary: #06b6d4; /* Cyan */
--accent-secondary: #3b82f6; /* Blue */
--accent-tertiary: #8b5cf6; /* Violet */
/* Glow Effects */
--glow-primary: rgba(6, 182, 212, 0.2);
--glow-secondary: rgba(59, 130, 246, 0.2);
--glow-tertiary: rgba(139, 92, 246, 0.2);
/* Border Colors */
--border-primary: rgba(255, 255, 255, 0.1);
--border-secondary: rgba(255, 255, 255, 0.05);
/* Card Background */
--card-bg: rgba(24, 28, 44, 0.5); /* Slightly different from original */
--card-border: rgba(56, 189, 248, 0.2); /* Cyan border */
/* UI Element Colors */
--ui-element: #1e293b;
--ui-element-hover: #334155;
/* Container Paddings */
--container-padding: clamp(1rem, 5vw, 3rem);
/* Font Sizes */
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-md: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-size-5xl: 3rem;
/* Font Families */
--font-mono: 'JetBrains Mono', monospace;
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
/* Reset Styles */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
height: 100%;
width: 100%;
overflow-x: hidden;
}
body {
font-family: var(--font-sans);
background-color: var(--bg-primary);
color: var(--text-primary);
font-size: var(--font-size-md);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-image:
radial-gradient(circle at 20% 35%, rgba(6, 182, 212, 0.05) 0%, transparent 50%),
radial-gradient(circle at 75% 15%, rgba(59, 130, 246, 0.05) 0%, transparent 45%),
radial-gradient(circle at 85% 70%, rgba(139, 92, 246, 0.05) 0%, transparent 40%);
position: relative;
}
/* Grid overlay effect */
body::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(rgba(226, 232, 240, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(226, 232, 240, 0.03) 1px, transparent 1px);
background-size: 30px 30px;
pointer-events: none;
z-index: -1;
}
a {
color: var(--accent-primary);
text-decoration: none;
transition: color 0.3s ease;
}
a:hover {
color: var(--accent-secondary);
}
img {
max-width: 100%;
height: auto;
display: block; /* Prevent bottom space */
}
h1, h2, h3, h4, h5, h6 {
margin: 0 0 1rem 0;
line-height: 1.2;
font-weight: 600;
color: var(--text-primary); /* Ensure headings use primary text color */
}
p {
margin-bottom: 1.5rem;
color: var(--text-secondary); /* Use secondary for paragraphs */
}
button {
cursor: pointer;
border: none;
outline: none;
font-family: var(--font-sans);
}
.container {
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--container-padding);
}
/* Neuronal nodes animation */
.neural-nodes { /* Changed from ID to class */
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1;
}
.neural-node {
position: absolute; /* Changed from fixed */
width: 2px;
height: 2px;
background: rgba(226, 232, 240, 0.2);
border-radius: 50%;
animation: pulse 4s infinite alternate ease-in-out;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.3;
}
100% {
transform: scale(1.5);
opacity: 0.6;
}
}
/* Floating gradient shapes */
.floating-shapes {
position: fixed; /* Changed from absolute */
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: -1; /* Ensure it's behind content */
}
.floating-shape {
position: absolute;
border-radius: 50%;
opacity: 0.05;
filter: blur(30px);
}
.shape-1 {
width: 300px;
height: 300px;
background: var(--accent-primary);
top: 20%;
right: 0;
}
.shape-2 {
width: 200px;
height: 200px;
background: var(--accent-secondary);
bottom: 10%;
left: 10%;
}
.shape-3 {
width: 150px;
height: 150px;
background: var(--accent-tertiary);
top: 70%;
right: 20%;
}
/* Main Header (Example - Adapt if using Header.astro component) */
.site-header {
background: linear-gradient(180deg, var(--bg-secondary), transparent);
padding: 1.5rem 0;
position: relative; /* Changed from sticky if needed */
z-index: 10;
border-bottom: 1px solid var(--border-primary);
}
.header-container {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1280px;
margin: 0 auto;
padding: 0 var(--container-padding);
}
/* Footer (Example - Adapt if using Footer.astro component) */
.site-footer {
background: var(--bg-secondary);
padding: 4rem 0 2rem;
border-top: 1px solid var(--border-primary);
margin-top: 4rem; /* Add space above footer */
}
/* Add other global styles from your external file if needed */
/* Or remove conflicting styles from the external file */
</style>
</head>
<body>
<!-- Neural Nodes Animation Container -->
<div class="neural-nodes"></div>
<!-- Floating Gradient Shapes Container -->
<div class="floating-shapes">
<div class="floating-shape shape-1"></div>
<div class="floating-shape shape-2"></div>
<div class="floating-shape shape-3"></div>
</div>
{/* Use slots for Header and Footer */}
<slot name="header" />
<main>
<slot /> {/* Default slot for page content */}
</main>
<slot name="footer" />
<!-- JavaScript for animations -->
<script>
// Create neural network nodes
document.addEventListener('DOMContentLoaded', function() {
const nodesContainer = document.querySelector('.neural-nodes');
if (nodesContainer) { // Check if container exists
const nodeCount = 30; // Adjust count as needed
for (let i = 0; i < nodeCount; i++) {
const node = document.createElement('div');
node.classList.add('neural-node');
// Random positioning
node.style.left = `${Math.random() * 100}%`;
node.style.top = `${Math.random() * 100}%`;
// Random animation delay
node.style.animationDelay = `${Math.random() * 5}s`;
nodesContainer.appendChild(node);
}
} else {
console.warn("Element with class 'neural-nodes' not found.");
}
// Terminal typing effect (if needed globally, otherwise keep in component)
// const typingElements = document.querySelectorAll('.terminal-typing');
// typingElements.forEach(typingElement => { ... });
});
</script>
</body>
</html>

575
src/layouts/BlogPost.astro Normal file
View File

@ -0,0 +1,575 @@
---
import BaseLayout from './BaseLayout.astro';
import type { CollectionEntry } from 'astro:content';
type Props = {
title: string;
description: string;
pubDate: Date;
updatedDate?: Date;
heroImage?: string;
category?: string;
tags?: string[];
draft?: boolean;
};
const { title, description, pubDate, updatedDate, heroImage, category, tags, draft } = Astro.props;
// Format date with time zone
const formattedDate = pubDate ? new Date(pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : '';
const formattedUpdatedDate = updatedDate ? new Date(updatedDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
}) : '';
// Social share URLs
const pageUrl = Astro.url.href;
const encodedUrl = encodeURIComponent(pageUrl);
const encodedTitle = encodeURIComponent(title);
const twitterShareUrl = `https://twitter.com/intent/tweet?url=${encodedUrl}&text=${encodedTitle}`;
const linkedinShareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodedUrl}`;
const facebookShareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`;
---
<BaseLayout title={title} description={description}>
<div class="article-container">
<article class="blog-post">
{draft && (
<div class="draft-banner">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path>
</svg>
<span>Draft Post - Content may change</span>
</div>
)}
{heroImage && <img width={1200} height={600} src={heroImage} alt="" class="hero-image" />}
<div class="post-header">
<h1 class="post-title">{title}</h1>
<div class="post-metadata">
<div class="post-info">
<time datetime={pubDate?.toISOString()} class="post-date">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{formattedDate}
</time>
{category && (
<span class="post-category">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
<line x1="7" y1="7" x2="7.01" y2="7"></line>
</svg>
{category}
</span>
)}
</div>
{updatedDate && (
<div class="updated-date">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
Updated: {formattedUpdatedDate}
</div>
)}
</div>
{tags && tags.length > 0 && (
<div class="post-tags">
{tags.map(tag => (
<span class="post-tag">#{tag}</span>
))}
</div>
)}
</div>
<div class="post-content">
<slot />
</div>
<div class="post-footer">
<div class="share-post">
<span class="share-title">Share this post:</span>
<div class="share-buttons">
<a href={twitterShareUrl} target="_blank" rel="noopener noreferrer" class="share-button twitter">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"></path>
</svg>
</a>
<a href={linkedinShareUrl} target="_blank" rel="noopener noreferrer" class="share-button linkedin">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path>
<rect x="2" y="9" width="4" height="12"></rect>
<circle cx="4" cy="4" r="2"></circle>
</svg>
</a>
<a href={facebookShareUrl} target="_blank" rel="noopener noreferrer" class="share-button facebook">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"></path>
</svg>
</a>
</div>
</div>
</div>
</article>
<aside class="blog-sidebar">
<div class="sidebar-section toc">
<h3 class="sidebar-title">Table of Contents</h3>
<div class="toc-content">
<!-- Table of contents will be populated via JavaScript -->
<div id="table-of-contents"></div>
</div>
</div>
<div class="sidebar-section author">
<h3 class="sidebar-title">About the Author</h3>
<div class="author-card">
<div class="author-avatar">DL</div>
<div class="author-info">
<div class="author-name">Daniel LaForce</div>
<div class="author-bio">DevOps & Infrastructure Engineer passionate about Kubernetes, automation, and self-hosting.</div>
</div>
</div>
<div class="author-links">
<a href="https://github.com/keyargo" target="_blank" rel="noopener noreferrer" class="author-link">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
</svg>
GitHub
</a>
<a href="https://linkedin.com/in/danlaforce" target="_blank" rel="noopener noreferrer" class="author-link">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path>
<rect x="2" y="9" width="4" height="12"></rect>
<circle cx="4" cy="4" r="2"></circle>
</svg>
LinkedIn
</a>
</div>
</div>
</aside>
</div>
</BaseLayout>
<style>
.article-container {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.blog-post {
background: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--card-border);
overflow: hidden;
position: relative;
}
.draft-banner {
display: flex;
align-items: center;
gap: 0.5rem;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
color: var(--bg-primary);
padding: 0.5rem 1rem;
font-weight: 500;
font-family: 'JetBrains Mono', monospace;
}
.hero-image {
width: 100%;
max-height: 400px;
object-fit: cover;
border-bottom: 1px solid var(--card-border);
}
.post-header {
padding: 2rem 2rem 1rem;
}
.post-title {
font-size: clamp(1.8rem, 4vw, 2.5rem);
line-height: 1.2;
margin-bottom: 1.5rem;
}
.post-metadata {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
font-family: 'JetBrains Mono', monospace;
color: var(--text-secondary);
}
.post-info {
display: flex;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
}
.post-date, .post-category, .updated-date {
display: flex;
align-items: center;
gap: 0.5rem;
}
.post-category {
padding: 0.25rem 0.75rem;
background: rgba(6, 182, 212, 0.1);
border-radius: 2rem;
border: 1px solid rgba(6, 182, 212, 0.2);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1rem 0;
}
.post-tag {
padding: 0.25rem 0.75rem;
background: rgba(139, 92, 246, 0.1);
border-radius: 2rem;
border: 1px solid rgba(139, 92, 246, 0.2);
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
color: var(--text-secondary);
transition: all 0.3s ease;
}
.post-tag:hover {
background: rgba(139, 92, 246, 0.2);
transform: translateY(-2px);
}
.post-content {
padding: 0 2rem 2rem;
color: var(--text-secondary);
line-height: 1.8;
}
.post-content :global(h1),
.post-content :global(h2),
.post-content :global(h3),
.post-content :global(h4) {
color: var(--text-primary);
margin: 2rem 0 1rem;
}
.post-content :global(h1) {
font-size: 2rem;
}
.post-content :global(h2) {
font-size: 1.75rem;
}
.post-content :global(h3) {
font-size: 1.5rem;
}
.post-content :global(h4) {
font-size: 1.25rem;
}
.post-content :global(p) {
margin-bottom: 1.5rem;
}
.post-content :global(pre) {
background: rgba(15, 23, 42, 0.8);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--card-border);
}
.post-content :global(code) {
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
}
.post-content :global(a) {
color: var(--accent-primary);
text-decoration: none;
border-bottom: 1px dashed var(--accent-primary);
transition: all 0.3s ease;
}
.post-content :global(a:hover) {
border-bottom: 1px solid var(--accent-primary);
}
.post-content :global(img) {
max-width: 100%;
border-radius: 0.5rem;
margin: 1.5rem 0;
}
.post-content :global(blockquote) {
border-left: 4px solid var(--accent-primary);
padding-left: 1rem;
font-style: italic;
margin: 1.5rem 0;
}
.post-content :global(ul),
.post-content :global(ol) {
margin: 1.5rem 0 1.5rem 1.5rem;
}
.post-content :global(li) {
margin-bottom: 0.5rem;
}
.post-footer {
padding: 1.5rem 2rem;
border-top: 1px solid var(--card-border);
}
.share-post {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
}
.share-title {
font-weight: 500;
color: var(--text-primary);
}
.share-buttons {
display: flex;
gap: 0.75rem;
}
.share-button {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(226, 232, 240, 0.05);
color: var(--text-primary);
transition: all 0.3s ease;
}
.share-button:hover {
transform: translateY(-3px);
}
.share-button.twitter:hover {
background: #1DA1F2;
color: white;
}
.share-button.linkedin:hover {
background: #0077B5;
color: white;
}
.share-button.facebook:hover {
background: #1877F2;
color: white;
}
.blog-sidebar {
display: none;
}
.sidebar-section {
background: var(--card-bg);
border-radius: 1rem;
border: 1px solid var(--card-border);
padding: 1.5rem;
margin-bottom: 2rem;
}
.sidebar-title {
font-size: 1.25rem;
margin-bottom: 1rem;
color: var(--text-primary);
position: relative;
padding-bottom: 0.5rem;
}
.sidebar-title::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
width: 50px;
height: 2px;
background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary));
}
.toc-content {
font-family: 'JetBrains Mono', monospace;
font-size: 0.9rem;
}
.toc-content :global(ul) {
list-style: none;
padding-left: 0;
}
.toc-content :global(li) {
margin-bottom: 0.5rem;
padding-left: 1rem;
position: relative;
}
.toc-content :global(li::before) {
content: '•';
position: absolute;
left: 0;
color: var(--accent-primary);
}
.toc-content :global(a) {
color: var(--text-secondary);
text-decoration: none;
transition: all 0.3s ease;
}
.toc-content :global(a:hover) {
color: var(--accent-primary);
}
.author-card {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.author-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: var(--bg-primary);
font-size: 1.5rem;
}
.author-info {
flex: 1;
}
.author-name {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 0.5rem;
color: var(--text-primary);
}
.author-bio {
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
}
.author-links {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
.author-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: rgba(226, 232, 240, 0.05);
border-radius: 0.5rem;
color: var(--text-primary);
text-decoration: none;
font-size: 0.9rem;
transition: all 0.3s ease;
}
.author-link:hover {
background: rgba(226, 232, 240, 0.1);
transform: translateY(-2px);
}
@media (min-width: 768px) {
.post-content {
font-size: 1.1rem;
}
}
@media (min-width: 1024px) {
.article-container {
grid-template-columns: 1fr 300px;
gap: 2rem;
}
.blog-sidebar {
display: block;
}
}
</style>
<!-- Table of Contents Generator -->
<script>
document.addEventListener('DOMContentLoaded', () => {
const headings = document.querySelectorAll('.post-content h2, .post-content h3');
if (headings.length === 0) return;
const tocContainer = document.getElementById('table-of-contents');
if (!tocContainer) return;
const toc = document.createElement('ul');
headings.forEach((heading, index) => {
// Add ID to heading if it doesn't have one
if (!heading.id) {
heading.id = `heading-${index}`;
}
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `#${heading.id}`;
a.textContent = heading.textContent;
// Add appropriate class based on heading level
if (heading.tagName === 'H3') {
li.style.paddingLeft = '1rem';
}
li.appendChild(a);
toc.appendChild(li);
});
tocContainer.appendChild(toc);
});
</script>

View File

@ -0,0 +1,346 @@
---
import BaseLayout from './BaseLayout.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import Newsletter from '../components/Newsletter.astro';
interface Props {
frontmatter: {
title: string;
description?: string;
pubDate: Date;
updatedDate?: Date;
heroImage?: string;
category?: string; // Keep category for potential filtering, but don't display in header
tags?: string[];
readTime?: string;
draft?: boolean;
author?: string; // Keep author field if needed elsewhere
// Add other potential frontmatter fields as optional
github?: string;
live?: string;
technologies?: string[];
}
}
const { frontmatter } = Astro.props;
const formattedPubDate = frontmatter.pubDate ? new Date(frontmatter.pubDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
}) : 'N/A';
const formattedUpdatedDate = frontmatter.updatedDate ? new Date(frontmatter.updatedDate).toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
}) : null;
// Default image if heroImage is missing
const displayImage = frontmatter.heroImage || '/images/placeholders/default.jpg';
---
<BaseLayout title={frontmatter.title} description={frontmatter.description} image={displayImage}>
<Header slot="header" />
<div class="blog-post-container">
<article class="blog-post">
<header class="blog-post-header">
{/* Display Draft Badge First */}
{frontmatter.draft && <span class="draft-badge mb-4">DRAFT</span>}
{/* Title (Smaller) */}
<h1 class="blog-post-title mb-2">{frontmatter.title}</h1>
{/* Description */}
{frontmatter.description && <p class="blog-post-description mb-4">{frontmatter.description}</p>}
{/* Metadata (Date, Read Time) */}
<div class="blog-post-meta mb-4">
<span class="blog-post-date">Published {formattedPubDate}</span>
{formattedUpdatedDate && (
<span class="blog-post-updated">(Updated {formattedUpdatedDate})</span>
)}
{frontmatter.readTime && <span class="blog-post-read-time">{frontmatter.readTime}</span>}
{/* Category removed from display here */}
</div>
{/* Tags */}
{frontmatter.tags && frontmatter.tags.length > 0 && (
<div class="blog-post-tags">
{frontmatter.tags.map((tag) => (
<a href={`/tag/${tag}`} class="blog-post-tag">#{tag}</a>
))}
</div>
)}
</header>
{/* Display Hero Image */}
{displayImage && (
<div class="blog-post-hero">
<img src={displayImage.startsWith('/') ? displayImage : `/${displayImage}`} alt={frontmatter.title} width="1024" height="512" loading="lazy" />
</div>
)}
{/* Main Content Area */}
<div class="blog-post-content prose prose-invert max-w-none">
<slot /> {/* Renders the actual markdown content */}
</div>
{/* Future Feature Placeholders remain commented out */}
{/* ... */}
</article>
{/* Sidebar */}
<aside class="blog-post-sidebar">
{/* Author Card Updated */}
<div class="sidebar-card author-card">
<div class="author-avatar">
<img src="/images/avatar.jpg" alt="LaForceIT Tech Blogs" />
</div>
<div class="author-info">
<h3>LaForceIT.com Tech Blogs</h3>
<p>For Home Labbers, Technologists & Engineers</p>
</div>
<p class="author-bio">
Exploring enterprise-grade infrastructure, automation, Kubernetes, and zero-trust networking in the home lab and beyond.
</p>
{/* Social links removed */}
</div>
{/* Table of Contents Card */}
<div class="sidebar-card toc-card">
<h3>Table of Contents</h3>
<nav class="toc-container" id="toc">
<p class="text-sm text-gray-400">Loading TOC...</p>
</nav>
</div>
{/* Future Feature Placeholders remain commented out */}
{/* ... */}
</aside>
</div>
<Newsletter />
<Footer slot="footer" />
</BaseLayout>
{/* Script for Table of Contents Generation (Unchanged) */}
<script>
function generateToc() {
const tocContainer = document.getElementById('toc');
const contentArea = document.querySelector('.blog-post-content');
if (!tocContainer || !contentArea) return;
const headings = contentArea.querySelectorAll('h2, h3');
if (headings.length > 0) {
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading) => {
let id = heading.id;
if (!id) {
id = heading.textContent?.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').replace(/--+/g, '-') || `heading-${Math.random().toString(36).substring(7)}`;
heading.id = id;
}
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.addEventListener('click', (e) => {
e.preventDefault();
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.innerHTML = '';
tocContainer.appendChild(tocList);
} else {
tocContainer.innerHTML = '<p class="text-sm text-gray-400">No sections found.</p>';
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', generateToc);
} else {
generateToc();
}
</script>
{/* Styles Updated */}
<style>
.draft-badge {
display: inline-block;
margin-bottom: 1rem;
padding: 0.25rem 0.75rem;
background-color: rgba(234, 179, 8, 0.2);
color: #ca8a04;
font-size: 0.8rem;
border-radius: 0.25rem;
font-weight: 600;
font-family: 'JetBrains Mono', monospace;
}
.blog-post-container {
display: grid;
/* Adjusted grid for wider TOC/Sidebar */
grid-template-columns: 7fr 2fr;
gap: 3rem; /* Wider gap */
max-width: 1400px; /* Wider max width */
margin: 2rem auto;
padding: 0 1.5rem;
}
.blog-post-header {
margin-bottom: 2.5rem;
border-bottom: 1px solid var(--card-border);
padding-bottom: 1.5rem;
}
.blog-post-title {
/* Made title slightly smaller */
font-size: clamp(1.8rem, 4vw, 2.5rem);
line-height: 1.25; /* Adjusted line height */
margin-bottom: 0.75rem; /* Adjusted margin */
color: var(--text-primary);
}
.blog-post-description {
font-size: 1.1rem;
color: var(--text-secondary);
margin-bottom: 1.5rem; /* Increased margin */
max-width: 75ch; /* Adjusted width */
}
.blog-post-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin-bottom: 1.5rem; /* Increased margin */
font-size: 0.85rem;
color: var(--text-secondary);
}
/* Removed .blog-post-category style */
.blog-post-tags {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0rem; /* Removed top margin */
}
.blog-post-tag {
color: var(--accent-secondary);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.3s ease;
font-family: 'JetBrains Mono', monospace;
background-color: rgba(59, 130, 246, 0.1);
padding: 0.2rem 0.6rem;
border-radius: 4px;
}
.blog-post-tag:hover {
color: var(--accent-primary);
background-color: rgba(6, 182, 212, 0.15);
}
.blog-post-hero {
width: 100%;
margin-bottom: 2.5rem;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--card-border);
background-color: var(--bg-secondary);
}
.blog-post-hero img {
width: 100%;
height: auto;
display: block;
}
.blog-post-content {
/* Styles inherited from prose */
}
.blog-post-sidebar {
position: sticky;
top: 2rem;
align-self: start;
height: calc(100vh - 4rem);
overflow-y: auto;
}
.sidebar-card {
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 10px;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.author-card {
text-align: center;
}
.author-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
margin: 0 auto 1rem;
border: 2px solid var(--accent-primary);
background-color: var(--bg-secondary);
}
.author-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.author-info h3 {
margin-bottom: 0.25rem;
color: var(--text-primary);
font-size: 1.1rem;
}
.author-info p { /* Target the subtitle */
color: var(--text-secondary);
margin-bottom: 1rem;
font-size: 0.9rem;
}
.author-bio { /* Target the main bio */
font-size: 0.9rem;
margin-bottom: 0; /* Remove bottom margin */
color: var(--text-secondary);
text-align: left;
}
/* Social links removed */
.toc-card h3 {
margin-bottom: 1rem;
color: var(--text-primary);
}
.toc-list {
list-style: none;
padding: 0;
margin: 0;
max-height: 60vh;
overflow-y: auto;
}
.toc-item {
margin-bottom: 0.9rem; /* Increased spacing */
}
.toc-item a {
color: var(--text-secondary);
text-decoration: none;
transition: color 0.3s ease;
font-size: 0.9rem;
display: block;
padding-left: 0;
line-height: 1.4; /* Improve readability */
}
.toc-item a:hover {
color: var(--accent-primary);
}
.toc-h3 a {
padding-left: 1.5rem; /* Increased indent */
font-size: 0.85rem;
opacity: 0.9;
}
@media (max-width: 1024px) {
.blog-post-container {
grid-template-columns: 1fr; /* Stack on smaller screens */
}
.blog-post-sidebar {
display: none;
}
}
</style>

0
src/pages/api/confirm.ts Normal file
View File

Some files were not shown because too many files have changed in this diff Show More