Refactor project structure and update files
|
@ -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#"
|
||||
}
|
|
@ -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#"
|
||||
}
|
|
@ -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#"
|
||||
}
|
|
@ -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#"
|
||||
}
|
|
@ -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#"
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export default new Map();
|
|
@ -0,0 +1 @@
|
|||
export default new Map();
|
|
@ -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");
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"_variables": {
|
||||
"lastUpdateCheck": 1745122883007
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
|
@ -1,6 +1,8 @@
|
|||
# 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/images/ -symlink
|
||||
public/blog/infrastructure/ -symlink
|
||||
|
@ -10,5 +12,24 @@ src/content/projects/ -symlink
|
|||
src/content/configurations/ -symlink
|
||||
src/content/external-posts/ -symlink
|
||||
|
||||
# Text files
|
||||
# Set text files to automatically normalize line endings
|
||||
* 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
|
||||
|
|
|
@ -1,6 +1,27 @@
|
|||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
# build output
|
||||
dist/
|
||||
.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
|
||||
|
|
|
@ -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.
|
|
@ -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: []
|
||||
}
|
||||
});
|
|
@ -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
|
||||
|
|
@ -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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 <none> 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>
|
|
@ -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
|
||||
|
|
@ -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
public/blog/images/configs/cloudflare-tunnel.png → public/assets/images/configs/cloudflare-tunnel.png
Executable file → Normal file
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
0
public/blog/images/configs/gitops-pipline.png → public/assets/images/configs/gitops-pipline.png
Executable file → Normal file
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
0
public/blog/images/configs/k3s-cluster-config.png → public/assets/images/configs/k3s-cluster-config.png
Executable file → Normal file
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.6 MiB |
0
public/blog/images/configs/prometheus-monitoring.png → public/assets/images/configs/prometheus-monitoring.png
Executable file → Normal file
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
0
public/blog/images/configs/traefik-config.png → public/assets/images/configs/traefik-config.png
Executable file → Normal file
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
0
public/blog/images/posts/cloudflare-tunnel-hero.png → public/assets/images/posts/cloudflare-tunnel-hero.png
Executable file → Normal file
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 2.2 MiB |
0
public/blog/images/posts/k3installation.png → public/assets/images/posts/k3installation.png
Executable file → Normal file
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.5 MiB |
0
public/blog/images/posts/prometheusk8.png → public/assets/images/posts/prometheusk8.png
Executable file → Normal file
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.4 MiB |
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
---
|
||||
```
|
|
@ -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"
|
||||
---
|
|
@ -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.
|
|
@ -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._
|
|
@ -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._
|
|
@ -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!
|
|
@ -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...
|
|
@ -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!
|
|
@ -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.
|
|
@ -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.
|
|
@ -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
|
||||
|
|
@ -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._
|
|
@ -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:
|
||||
|
||||

|
||||
|
||||
## 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._
|
|
@ -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.
|
|
@ -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.
|
|
@ -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._
|
|
@ -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 |
|
@ -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=
|
After Width: | Height: | Size: 2.2 MiB |
|
@ -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=
|
After Width: | Height: | Size: 2.5 MiB |
|
@ -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 |
After Width: | Height: | Size: 2.4 MiB |
|
@ -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."
|
|
@ -1,28 +1,58 @@
|
|||
#!/bin/bash
|
||||
# Script to handle symbolic links before commit
|
||||
echo "Processing symbolic links for content..."
|
||||
|
||||
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"
|
||||
# Configuration
|
||||
BLOG_CONTENT_PATH="src/content"
|
||||
ASSETS_PATH="public/assets"
|
||||
BLOG_SOURCE="src/content/blog"
|
||||
|
||||
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"
|
||||
# Create necessary directories if they don't exist
|
||||
mkdir -p "$BLOG_CONTENT_PATH/posts"
|
||||
mkdir -p "$BLOG_CONTENT_PATH/configurations"
|
||||
mkdir -p "$BLOG_CONTENT_PATH/projects"
|
||||
mkdir -p "$ASSETS_PATH/images"
|
||||
|
||||
# Function to process markdown files
|
||||
process_markdown() {
|
||||
local file="$1"
|
||||
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
|
||||
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"
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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">
|
||||
© {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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
};
|
|
@ -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,
|
||||
};
|
14
public/blog/configs/git-symlinks-setup.md → src/content/configurations/git-symlinks-setup.md
Executable file → Normal 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
|
||||
|
||||
## Overview
|
|
@ -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.
|
|
@ -2,7 +2,7 @@
|
|||
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
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
category: networking
|
||||
tags:
|
||||
- cloudflare
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
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
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
category: networking
|
||||
tags:
|
||||
- cloudflare
|
||||
|
|
|
@ -10,7 +10,7 @@ tags:
|
|||
- kubernetes
|
||||
- docker
|
||||
- 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?
|
||||
|
|
|
@ -10,7 +10,7 @@ tags:
|
|||
- self-hosted
|
||||
- devops
|
||||
- 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.
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
title: GitOps with Flux CD
|
||||
description: Implementing GitOps workflows on Kubernetes using Flux CD
|
||||
pubDate: 2025-04-19
|
||||
heroImage: /blog/images/posts/prometheusk8.png
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
category: devops
|
||||
tags:
|
||||
- kubernetes
|
||||
|
|
|
@ -1,18 +1,87 @@
|
|||
---
|
||||
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
|
||||
title: "Setting Up a K3s Kubernetes Cluster"
|
||||
description: "A comprehensive guide to setting up a K3s cluster for your home lab or edge environment, with high availability and persistent storage."
|
||||
pubDate: "2023-11-15"
|
||||
heroImage: "/blog/images/posts/k3installation.png"
|
||||
category: "Kubernetes"
|
||||
tags: ["kubernetes", "k3s", "homelab", "infrastructure"]
|
||||
draft: false
|
||||
heroImage:
|
||||
---
|
||||
|
||||
# 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,7 +2,7 @@
|
|||
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
|
||||
heroImage: /images/posts/k3installation.png
|
||||
category: kubernetes
|
||||
tags:
|
||||
- kubernetes
|
||||
|
|
|
@ -1,142 +1,64 @@
|
|||
---
|
||||
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
|
||||
title: "Monitoring Your Kubernetes Cluster with Prometheus and Grafana"
|
||||
description: "A comprehensive guide to setting up a robust monitoring solution for your Kubernetes cluster using Prometheus and Grafana."
|
||||
pubDate: "2023-09-25"
|
||||
heroImage: "/blog/images/posts/prometheus-dashboard.svg"
|
||||
category: "Monitoring"
|
||||
tags: ["kubernetes", "prometheus", "grafana", "monitoring", "observability"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
# 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
|
||||
- **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
|
||||
Together, they form a powerful monitoring stack that provides insights into your cluster's health and performance.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Kubernetes cluster (K3s, EKS, GKE, etc.)
|
||||
- kubectl configured to access your cluster
|
||||
Before we begin, ensure you have:
|
||||
|
||||
- A running Kubernetes cluster (this guide uses K3s)
|
||||
- `kubectl` configured to communicate with your cluster
|
||||
- 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
|
||||
# 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 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
|
||||
# Install the kube-prometheus-stack
|
||||
helm install prometheus prometheus-community/kube-prometheus-stack \
|
||||
--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
|
||||
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
|
||||
By default, the services are not exposed outside the cluster. To access them, you can use port-forwarding:
|
||||
|
||||
### Grafana
|
||||
|
||||
|
@ -144,40 +66,31 @@ Then access Prometheus at http://localhost:9090
|
|||
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
|
||||
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
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: prometheus-ingress
|
||||
name: grafana-ingress
|
||||
namespace: monitoring
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
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:
|
||||
|
@ -188,102 +101,126 @@ spec:
|
|||
name: prometheus-grafana
|
||||
port:
|
||||
number: 80
|
||||
- host: alertmanager.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: prometheus-alertmanager
|
||||
port:
|
||||
number: 9093
|
||||
tls:
|
||||
- hosts:
|
||||
- grafana.example.com
|
||||
secretName: grafana-tls
|
||||
```
|
||||
|
||||
## 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
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: PrometheusRule
|
||||
metadata:
|
||||
name: node-alerts
|
||||
namespace: monitoring
|
||||
labels:
|
||||
release: prometheus
|
||||
prometheus:
|
||||
prometheusSpec:
|
||||
retention: 15d
|
||||
resources:
|
||||
requests:
|
||||
memory: 2Gi
|
||||
cpu: 500m
|
||||
limits:
|
||||
memory: 4Gi
|
||||
cpu: 1000m
|
||||
storageSpec:
|
||||
volumeClaimTemplate:
|
||||
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 }}"
|
||||
storageClassName: standard
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 50Gi
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
```bash
|
||||
helm upgrade prometheus prometheus-community/kube-prometheus-stack \
|
||||
--namespace monitoring \
|
||||
-f values.yaml
|
||||
```
|
||||
|
||||
## 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.
|
||||
1. **Insufficient Resources**: If pods are crashing, check if they have enough resources allocated
|
||||
2. **Connectivity Issues**: Ensure services can communicate with each other
|
||||
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
|
||||
|
||||
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,7 +2,7 @@
|
|||
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
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
category: infrastructure
|
||||
tags:
|
||||
- proxmox
|
||||
|
|
|
@ -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
|
||||
|
|
@ -10,7 +10,7 @@ tags:
|
|||
- digital-garden
|
||||
- knowledge-management
|
||||
- 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?
|
||||
|
|
|
@ -10,7 +10,7 @@ tags:
|
|||
- k3s
|
||||
- devops
|
||||
- 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?
|
||||
|
@ -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:
|
||||
|
||||

|
||||

|
||||
|
||||
## Creating Deployments Through the UI
|
||||
|
||||
|
|
|
@ -1,40 +1,57 @@
|
|||
---
|
||||
title: Starting My Digital Garden
|
||||
pubDate: 2025-04-19
|
||||
tags:
|
||||
- blog
|
||||
- meta
|
||||
- digital-garden
|
||||
title: "Starting My Digital Garden"
|
||||
description: "How and why I'm approaching this blog as a digital garden rather than a traditional chronological blog."
|
||||
pubDate: "2023-10-05"
|
||||
heroImage: "/blog/images/placeholders/default.jpg"
|
||||
category: "Meta"
|
||||
tags: ["digital-garden", "knowledge-management", "learning-in-public"]
|
||||
draft: false
|
||||
heroImage: /blog/images/posts/prometheusk8.png
|
||||
---
|
||||
|
||||
# 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
|
||||
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.
|
||||
- **Living documents**: Content is regularly revisited and updated as my understanding evolves
|
||||
- **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
|
||||
- **Learning in public**: Sharing my learning process, not just the finished product
|
||||
|
||||
## 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
|
||||
- Learning notes and discoveries
|
||||
- Workflow and productivity systems
|
||||
- Occasional personal reflections
|
||||
## How This Works
|
||||
|
||||
## 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.*
|
||||
|
|
|
@ -6,7 +6,7 @@ category: Test
|
|||
tags:
|
||||
- test
|
||||
draft: true
|
||||
heroImage: /blog/images/posts/prometheusk8.png
|
||||
heroImage: /images/posts/prometheusk8.png
|
||||
---
|
||||
|
||||
# Test Post
|
||||
|
|
|
@ -10,7 +10,7 @@ tags:
|
|||
- self-hosted
|
||||
- coding
|
||||
- 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.
|
||||
|
|
|
@ -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,0 +1 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|