Compare commits
5 Commits
affc1b0dc9
...
1b3788d587
Author | SHA1 | Date | |
---|---|---|---|
1b3788d587 | |||
4409625260 | |||
dc75b39596 | |||
aa66156388 | |||
780b06b9cf |
12
node_modules/.vite/deps/_metadata.json
generated
vendored
12
node_modules/.vite/deps/_metadata.json
generated
vendored
@ -1,25 +1,25 @@
|
|||||||
{
|
{
|
||||||
"hash": "6c87a632",
|
"hash": "7113b21e",
|
||||||
"configHash": "e55fbbff",
|
"configHash": "2cd4a4ea",
|
||||||
"lockfileHash": "53cd0e09",
|
"lockfileHash": "53cd0e09",
|
||||||
"browserHash": "e114ea7e",
|
"browserHash": "f4d46c12",
|
||||||
"optimized": {
|
"optimized": {
|
||||||
"astro > cssesc": {
|
"astro > cssesc": {
|
||||||
"src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js",
|
"src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js",
|
||||||
"file": "astro___cssesc.js",
|
"file": "astro___cssesc.js",
|
||||||
"fileHash": "7ff5e5f3",
|
"fileHash": "dc615560",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"astro > aria-query": {
|
"astro > aria-query": {
|
||||||
"src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
|
"src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
|
||||||
"file": "astro___aria-query.js",
|
"file": "astro___aria-query.js",
|
||||||
"fileHash": "c7870714",
|
"fileHash": "53d05d83",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
},
|
},
|
||||||
"astro > axobject-query": {
|
"astro > axobject-query": {
|
||||||
"src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
|
"src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
|
||||||
"file": "astro___axobject-query.js",
|
"file": "astro___axobject-query.js",
|
||||||
"fileHash": "dc6eafc6",
|
"fileHash": "35e7ec58",
|
||||||
"needsInterop": true
|
"needsInterop": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
76
src/components/common/SearchBar.astro
Normal file
76
src/components/common/SearchBar.astro
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
placeholder?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
placeholder = "Search...",
|
||||||
|
ariaLabel = "Search",
|
||||||
|
className = "",
|
||||||
|
} = Astro.props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class={`search-container ${className}`}>
|
||||||
|
<!-- Hidden live region for screen readers -->
|
||||||
|
<div
|
||||||
|
id="search-status"
|
||||||
|
class="sr-only"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="true"
|
||||||
|
>
|
||||||
|
Showing all services
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<label for="app-search" class="sr-only">{ariaLabel}</label>
|
||||||
|
<!-- Search icon -->
|
||||||
|
<div class="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none">
|
||||||
|
<svg class="w-4 h-4 zag-text" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="app-search"
|
||||||
|
type="text"
|
||||||
|
x-model="searchQuery"
|
||||||
|
placeholder={placeholder}
|
||||||
|
role="searchbox"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-describedby="search-status search-hint"
|
||||||
|
aria-controls="app-list"
|
||||||
|
class="w-full pl-8 pr-8 py-1 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-current zag-text zag-bg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
x-show="searchQuery"
|
||||||
|
@click="searchQuery = ''"
|
||||||
|
aria-label="Clear search"
|
||||||
|
class="absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 zag-text" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tooltip area - shows either help text or status text -->
|
||||||
|
<div class="mt-1 h-5 text-center"> <!-- Fixed height to prevent layout shift -->
|
||||||
|
<!-- Keyboard shortcut hint - shown when search is empty -->
|
||||||
|
<div
|
||||||
|
id="search-hint"
|
||||||
|
class="text-xs zag-text"
|
||||||
|
x-show="searchQuery === ''"
|
||||||
|
>
|
||||||
|
<kbd class="px-1 py-0.5 text-xs border border-current rounded zag-text">/ </kbd> to search, <kbd class="px-1 py-0.5 text-xs border border-current rounded zag-text">Esc</kbd> to clear
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status text - shown when user is typing -->
|
||||||
|
<div
|
||||||
|
id="visible-status"
|
||||||
|
class="text-xs zag-text"
|
||||||
|
x-show="searchQuery !== ''"
|
||||||
|
x-text="hasResults ? 'Found ' + visibleCount + ' services' : 'No services found'"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
73
src/components/homelab/CategorySection.astro
Normal file
73
src/components/homelab/CategorySection.astro
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
interface Props {
|
||||||
|
category: string;
|
||||||
|
apps: Array<{
|
||||||
|
name: string;
|
||||||
|
link: string;
|
||||||
|
icon: string;
|
||||||
|
alt: string;
|
||||||
|
tags?: string[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { category, apps } = Astro.props;
|
||||||
|
import ServiceCard from "../common/ServiceCard.astro";
|
||||||
|
|
||||||
|
// Pre-compute values during server-side rendering
|
||||||
|
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
|
||||||
|
const categoryLower = category.toLowerCase();
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="mb-8 category-section" data-category={categoryLower} x-data="{ open: true }">
|
||||||
|
<button
|
||||||
|
@click="open = !open"
|
||||||
|
class="text-xl font-semibold mb-4 w-full text-left flex items-center justify-between"
|
||||||
|
aria-expanded="true"
|
||||||
|
:aria-expanded="open.toString()"
|
||||||
|
aria-controls={categoryId}
|
||||||
|
>
|
||||||
|
{category}
|
||||||
|
<svg
|
||||||
|
:class="{ 'rotate-180': open }"
|
||||||
|
class="w-5 h-5 transform transition-transform duration-300"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-transition
|
||||||
|
id={categoryId}
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
{apps.length > 0 ? (
|
||||||
|
apps.map(app => {
|
||||||
|
const appName = app.name.toLowerCase();
|
||||||
|
const appTags = app.tags ? app.tags.join(' ').toLowerCase() : '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="app-card transition-all duration-300"
|
||||||
|
data-app-name={appName}
|
||||||
|
data-app-tags={appTags}
|
||||||
|
data-app-category={categoryLower}
|
||||||
|
>
|
||||||
|
<ServiceCard
|
||||||
|
name={app.name}
|
||||||
|
href={app.link}
|
||||||
|
img={app.icon}
|
||||||
|
alt={app.name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p class="text-center col-span-full">Coming soon...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
100
src/components/homelab/searchUtils.js
Normal file
100
src/components/homelab/searchUtils.js
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Initialize search functionality for the homelab page
|
||||||
|
* This function sets up the search filtering, keyboard shortcuts,
|
||||||
|
* and status updates for the search feature
|
||||||
|
*/
|
||||||
|
function initializeSearch() {
|
||||||
|
return {
|
||||||
|
searchQuery: '',
|
||||||
|
hasResults: true,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.setupWatchers();
|
||||||
|
this.setupKeyboardShortcuts();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupWatchers() {
|
||||||
|
this.$watch('searchQuery', (query) => {
|
||||||
|
this.filterServices(query);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setupKeyboardShortcuts() {
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// '/' key focuses the search input
|
||||||
|
if (e.key === '/' && document.activeElement.id !== 'app-search') {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('app-search').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape key clears the search
|
||||||
|
if (e.key === 'Escape' && this.searchQuery !== '') {
|
||||||
|
this.searchQuery = '';
|
||||||
|
document.getElementById('app-search').focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
filterServices(query) {
|
||||||
|
query = query.toLowerCase();
|
||||||
|
let anyResults = false;
|
||||||
|
let visibleCount = 0;
|
||||||
|
|
||||||
|
// Process all service cards
|
||||||
|
document.querySelectorAll('.app-card').forEach((card) => {
|
||||||
|
const serviceName = card.getAttribute('data-app-name') || '';
|
||||||
|
const serviceTags = card.getAttribute('data-app-tags') || '';
|
||||||
|
const serviceCategory = card.getAttribute('data-app-category') || '';
|
||||||
|
|
||||||
|
const isMatch = query === '' ||
|
||||||
|
serviceName.includes(query) ||
|
||||||
|
serviceTags.includes(query) ||
|
||||||
|
serviceCategory.includes(query);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
card.style.display = '';
|
||||||
|
anyResults = true;
|
||||||
|
visibleCount++;
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateCategoryVisibility(query);
|
||||||
|
this.updateResultsStatus(query, anyResults, visibleCount);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCategoryVisibility(query) {
|
||||||
|
document.querySelectorAll('.category-section').forEach((category) => {
|
||||||
|
const hasVisibleApps = Array.from(
|
||||||
|
category.querySelectorAll('.app-card')
|
||||||
|
).some((card) => card.style.display !== 'none');
|
||||||
|
|
||||||
|
if (query === '' || hasVisibleApps) {
|
||||||
|
category.style.display = '';
|
||||||
|
} else {
|
||||||
|
category.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateResultsStatus(query, anyResults, visibleCount) {
|
||||||
|
// Update results status
|
||||||
|
this.hasResults = query === '' || anyResults;
|
||||||
|
|
||||||
|
// Update screen reader status
|
||||||
|
const statusEl = document.getElementById('search-status');
|
||||||
|
if (statusEl) {
|
||||||
|
if (query === '') {
|
||||||
|
statusEl.textContent = 'Showing all services';
|
||||||
|
} else if (this.hasResults) {
|
||||||
|
statusEl.textContent = 'Found ' + visibleCount + ' services matching ' + query;
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'No services found matching ' + query;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { initializeSearch };
|
@ -16,6 +16,7 @@ import "../styles/global.css";
|
|||||||
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i"
|
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
|
<script src="https://unpkg.com/alpinejs" defer></script>
|
||||||
<slot name="head" />
|
<slot name="head" />
|
||||||
</head>
|
</head>
|
||||||
<body class="zag-bg zag-text zag-transition font-mono">
|
<body class="zag-bg zag-text zag-transition font-mono">
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
import { GLOBAL } from "../../lib/variables";
|
import { GLOBAL } from "../../lib/variables";
|
||||||
import Layout from "../../layouts/Layout.astro";
|
import Layout from "../../layouts/Layout.astro";
|
||||||
import Section from "../../components/common/Section.astro";
|
import Section from "../../components/common/Section.astro";
|
||||||
import ServiceCard from "../../components/common/ServiceCard.astro";
|
import SearchBar from "../../components/common/SearchBar.astro";
|
||||||
|
import CategorySection from "../../components/homelab/CategorySection.astro";
|
||||||
import { services } from "./services.ts";
|
import { services } from "./services.ts";
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -24,29 +25,132 @@ import { services } from "./services.ts";
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
||||||
<Section class="my-8">
|
<Section class="my-8">
|
||||||
<div class="flex items-center gap-4 pt-8 pb-4">
|
<div x-data="{
|
||||||
<h1 class="font-display text-3xl sm:text-4xl leading-loose">Homelab</h1>
|
searchQuery: '',
|
||||||
</div>
|
hasResults: true,
|
||||||
|
visibleCount: 0,
|
||||||
{Object.entries(services).map(([category, apps]) => (
|
|
||||||
<div class="mb-8">
|
init() {
|
||||||
<h3 class="text-xl font-semibold mb-4">{category}</h3>
|
// Initialize the visible count
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
this.visibleCount = document.querySelectorAll('.app-card').length;
|
||||||
{apps.length > 0 ? (
|
this.setupWatchers();
|
||||||
apps.map(app => (
|
this.setupKeyboardShortcuts();
|
||||||
<ServiceCard
|
},
|
||||||
name={app.name}
|
|
||||||
href={app.link}
|
setupWatchers() {
|
||||||
img={app.icon}
|
this.$watch('searchQuery', (query) => {
|
||||||
alt={app.name}
|
this.filterServices(query);
|
||||||
key={app.link}
|
});
|
||||||
/>
|
},
|
||||||
))
|
|
||||||
) : (
|
setupKeyboardShortcuts() {
|
||||||
<p class="text-center col-span-full">Coming soon...</p>
|
document.addEventListener('keydown', (e) => {
|
||||||
)}
|
// '/' key focuses the search input
|
||||||
|
if (e.key === '/' && document.activeElement.id !== 'app-search') {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('app-search').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape key clears the search
|
||||||
|
if (e.key === 'Escape' && this.searchQuery !== '') {
|
||||||
|
this.searchQuery = '';
|
||||||
|
document.getElementById('app-search').focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
filterServices(query) {
|
||||||
|
query = query.toLowerCase();
|
||||||
|
let anyResults = false;
|
||||||
|
let visibleCount = 0;
|
||||||
|
|
||||||
|
// Process all service cards
|
||||||
|
document.querySelectorAll('.app-card').forEach((card) => {
|
||||||
|
const serviceName = card.getAttribute('data-app-name') || '';
|
||||||
|
const serviceTags = card.getAttribute('data-app-tags') || '';
|
||||||
|
const serviceCategory = card.getAttribute('data-app-category') || '';
|
||||||
|
|
||||||
|
const isMatch = query === '' ||
|
||||||
|
serviceName.includes(query) ||
|
||||||
|
serviceTags.includes(query) ||
|
||||||
|
serviceCategory.includes(query);
|
||||||
|
|
||||||
|
if (isMatch) {
|
||||||
|
card.style.display = '';
|
||||||
|
anyResults = true;
|
||||||
|
visibleCount++;
|
||||||
|
} else {
|
||||||
|
card.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateCategoryVisibility(query);
|
||||||
|
this.updateResultsStatus(query, anyResults, visibleCount);
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCategoryVisibility(query) {
|
||||||
|
document.querySelectorAll('.category-section').forEach((category) => {
|
||||||
|
const hasVisibleApps = Array.from(
|
||||||
|
category.querySelectorAll('.app-card')
|
||||||
|
).some((card) => card.style.display !== 'none');
|
||||||
|
|
||||||
|
if (query === '' || hasVisibleApps) {
|
||||||
|
category.style.display = '';
|
||||||
|
} else {
|
||||||
|
category.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updateResultsStatus(query, anyResults, count) {
|
||||||
|
// Update results status
|
||||||
|
this.hasResults = query === '' || anyResults;
|
||||||
|
this.visibleCount = count;
|
||||||
|
|
||||||
|
// Update screen reader status
|
||||||
|
const statusEl = document.getElementById('search-status');
|
||||||
|
if (statusEl) {
|
||||||
|
if (query === '') {
|
||||||
|
statusEl.textContent = 'Showing all services';
|
||||||
|
this.visibleCount = document.querySelectorAll('.app-card').length;
|
||||||
|
} else if (this.hasResults) {
|
||||||
|
statusEl.textContent = 'Found ' + count + ' services matching ' + query;
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = 'No services found matching ' + query;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}" x-init="init()">
|
||||||
|
<!-- Search container - positioned at the top -->
|
||||||
|
<div class="mb-4 pt-0">
|
||||||
|
<div class="w-full">
|
||||||
|
<SearchBar
|
||||||
|
placeholder="Search services..."
|
||||||
|
ariaLabel="Search services"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
|
<!-- Page heading - now below search -->
|
||||||
|
<div class="flex items-center gap-4 pb-4 mt-2">
|
||||||
|
<h1 class="font-display text-3xl sm:text-4xl leading-loose">Homelab</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No results message -->
|
||||||
|
<div
|
||||||
|
x-show="searchQuery !== '' && !hasResults"
|
||||||
|
x-transition
|
||||||
|
class="text-center py-8 my-4 border-2 border-dashed border-current zag-text rounded-lg"
|
||||||
|
>
|
||||||
|
<p class="text-xl font-semibold zag-text">No Results</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service categories -->
|
||||||
|
<div id="app-list">
|
||||||
|
{Object.entries(services).map(([category, apps]) => (
|
||||||
|
<CategorySection category={category} apps={apps} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
@ -12,43 +12,50 @@ export const services = {
|
|||||||
name: "Silverbullet",
|
name: "Silverbullet",
|
||||||
link: "https://notes.justin.deal",
|
link: "https://notes.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/silverbullet.png",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/silverbullet.png",
|
||||||
alt: "Silverbullet"
|
alt: "Silverbullet",
|
||||||
|
tags: ["notes", "markdown", "knowledge base"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Vikunja",
|
name: "Vikunja",
|
||||||
link: "https://todo.justin.deal",
|
link: "https://todo.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/vikunja.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/vikunja.svg",
|
||||||
alt: "Vikunja"
|
alt: "Vikunja",
|
||||||
|
tags: ["todo", "tasks", "productivity"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Actual",
|
name: "Actual",
|
||||||
link: "https://budget.justin.deal",
|
link: "https://budget.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/actual-budget.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/actual-budget.svg",
|
||||||
alt: "Actual"
|
alt: "Actual",
|
||||||
|
tags: ["finance", "budget", "money"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Searxng",
|
name: "Searxng",
|
||||||
link: "https://search.justin.deal",
|
link: "https://search.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/searxng.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/searxng.svg",
|
||||||
alt: "Searxng"
|
alt: "Searxng",
|
||||||
|
tags: ["search", "privacy", "metasearch"]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "TeslaMate",
|
name: "TeslaMate",
|
||||||
link: "https://tesla.justin.deal",
|
link: "https://tesla.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/teslamate.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/teslamate.svg",
|
||||||
alt: "TeslaMate"
|
alt: "TeslaMate",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "BaiKal",
|
name: "BaiKal",
|
||||||
link: "https://dav.justin.deal",
|
link: "https://dav.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/baikal.png",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/baikal.png",
|
||||||
alt: "BaiKal"
|
alt: "BaiKal",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Cryptpad",
|
name: "Cryptpad",
|
||||||
link: "https://docs.justin.deal",
|
link: "https://docs.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/cryptpad.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/cryptpad.svg",
|
||||||
alt: "Cryptpad"
|
alt: "Cryptpad",
|
||||||
|
tags: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
Development: [
|
Development: [
|
||||||
@ -56,19 +63,22 @@ export const services = {
|
|||||||
name: "Gitea",
|
name: "Gitea",
|
||||||
link: "https://code.justin.deal",
|
link: "https://code.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg",
|
||||||
alt: "Gitea"
|
alt: "Gitea",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "OpenGist",
|
name: "OpenGist",
|
||||||
link: "https://snippets.justin.deal",
|
link: "https://snippets.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/opengist.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/opengist.svg",
|
||||||
alt: "OpenGist"
|
alt: "OpenGist",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "IT-Tools",
|
name: "IT-Tools",
|
||||||
link: "https://tools.justin.deal",
|
link: "https://tools.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/it-tools.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/it-tools.svg",
|
||||||
alt: "IT-Tools"
|
alt: "IT-Tools",
|
||||||
|
tags: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
Media: [
|
Media: [
|
||||||
@ -76,13 +86,15 @@ export const services = {
|
|||||||
name: "Jellyfin",
|
name: "Jellyfin",
|
||||||
link: "https://watch.justin.deal",
|
link: "https://watch.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/jellyfin.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/jellyfin.svg",
|
||||||
alt: "Jellyfin"
|
alt: "Jellyfin",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Calibre-Web",
|
name: "Calibre-Web",
|
||||||
link: "https://books.justin.deal",
|
link: "https://books.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/calibre-web.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/calibre-web.svg",
|
||||||
alt: "Calibre-Web"
|
alt: "Calibre-Web",
|
||||||
|
tags: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
Infrastructure: [
|
Infrastructure: [
|
||||||
@ -90,37 +102,43 @@ export const services = {
|
|||||||
name: "Pi-hole",
|
name: "Pi-hole",
|
||||||
link: "https://pihole.justin.deal",
|
link: "https://pihole.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/pi-hole.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/pi-hole.svg",
|
||||||
alt: "Pi-hole"
|
alt: "Pi-hole",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Ntfy",
|
name: "Ntfy",
|
||||||
link: "https://ntfy.justin.deal",
|
link: "https://ntfy.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/ntfy.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/ntfy.svg",
|
||||||
alt: "Ntfy"
|
alt: "Ntfy",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Vaultwarden",
|
name: "Vaultwarden",
|
||||||
link: "https://passwords.justin.deal",
|
link: "https://passwords.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/vaultwarden.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/vaultwarden.svg",
|
||||||
alt: "Vaultwarden"
|
alt: "Vaultwarden",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Uptime Kuma",
|
name: "Uptime Kuma",
|
||||||
link: "https://status.justin.deal",
|
link: "https://status.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/uptime-kuma.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/uptime-kuma.svg",
|
||||||
alt: "Uptime Kuma"
|
alt: "Uptime Kuma",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Authentik",
|
name: "Authentik",
|
||||||
link: "https://auth.justin.deal",
|
link: "https://auth.justin.deal",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/authentik.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/authentik.svg",
|
||||||
alt: "Authentik"
|
alt: "Authentik",
|
||||||
|
tags: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Traefik",
|
name: "Traefik",
|
||||||
link: "https://proxy.justin.deal:8080",
|
link: "https://proxy.justin.deal:8080",
|
||||||
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/traefik.svg",
|
icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/traefik.svg",
|
||||||
alt: "Traefik"
|
alt: "Traefik",
|
||||||
|
tags: []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user