mirror of
https://github.com/CorentinTh/it-tools.git
synced 2026-08-31 15:30:25 +00:00
parent
d505845f91
commit
59a70ebfa0
12 changed files with 341 additions and 13 deletions
11
README.md
11
README.md
|
|
@ -18,6 +18,17 @@ You have an idea of a tool? Submit a [feature request](https://github.com/Corent
|
|||
|
||||
Self host solutions for your homelab
|
||||
|
||||
**Cloudflare Pages + Access**
|
||||
|
||||
This repo also works as a Cloudflare-hosted static app. Build with `pnpm build`, deploy the `dist/` output to Cloudflare Pages, and attach a D1 database for favorites persistence.
|
||||
|
||||
Recommended setup:
|
||||
|
||||
- Protect the Pages hostname with Cloudflare Access.
|
||||
- Allow only the IdP groups or email domains you want to use the app.
|
||||
- Bind a D1 database named `it-tools-favorites` to the Pages project.
|
||||
- Keep the SPA fallback enabled so direct tool URLs refresh correctly.
|
||||
|
||||
**From docker hub:**
|
||||
|
||||
```sh
|
||||
|
|
|
|||
10
functions/_lib/d1.ts
Normal file
10
functions/_lib/d1.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export interface D1PreparedStatement {
|
||||
bind(...values: unknown[]): D1PreparedStatement;
|
||||
all<T = Record<string, unknown>>(): Promise<{ results: T[] }>;
|
||||
run(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface D1Database {
|
||||
prepare(query: string): D1PreparedStatement;
|
||||
batch(statements: D1PreparedStatement[]): Promise<unknown>;
|
||||
}
|
||||
15
functions/_lib/favorites.ts
Normal file
15
functions/_lib/favorites.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { normalizeFavoriteToolPaths } from '../../shared/favorites';
|
||||
|
||||
export function getAuthenticatedUserId(request: Request) {
|
||||
const email = request.headers.get('cf-access-authenticated-user-email')?.trim().toLowerCase();
|
||||
|
||||
return email || null;
|
||||
}
|
||||
|
||||
export function normalizeStoredFavoriteToolPaths(paths: unknown) {
|
||||
if (!Array.isArray(paths)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return normalizeFavoriteToolPaths(paths.filter((value): value is string => typeof value === 'string'));
|
||||
}
|
||||
70
functions/api/favorites.ts
Normal file
70
functions/api/favorites.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { getAuthenticatedUserId, normalizeStoredFavoriteToolPaths } from '../_lib/favorites';
|
||||
import type { D1Database } from '../_lib/d1';
|
||||
|
||||
type Env = {
|
||||
DB: D1Database;
|
||||
};
|
||||
|
||||
function json(data: unknown, init?: ResponseInit) {
|
||||
return Response.json(data, {
|
||||
headers: {
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function readFavoriteToolPaths(env: Env, userId: string) {
|
||||
const result = await env.DB.prepare(
|
||||
'SELECT tool_path FROM favorites WHERE user_id = ? ORDER BY sort_order ASC',
|
||||
)
|
||||
.bind(userId)
|
||||
.all<{ tool_path: string }>();
|
||||
|
||||
return result.results.map(row => row.tool_path);
|
||||
}
|
||||
|
||||
async function saveFavoriteToolPaths(env: Env, userId: string, favoriteToolPaths: string[]) {
|
||||
const normalizedFavoriteToolPaths = normalizeStoredFavoriteToolPaths(favoriteToolPaths);
|
||||
const statements = [
|
||||
env.DB.prepare('DELETE FROM favorites WHERE user_id = ?').bind(userId),
|
||||
...normalizedFavoriteToolPaths.map((toolPath, index) => env.DB.prepare(
|
||||
`INSERT INTO favorites (user_id, tool_path, sort_order)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(user_id, tool_path) DO UPDATE SET
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = CURRENT_TIMESTAMP`,
|
||||
).bind(userId, toolPath, index)),
|
||||
];
|
||||
|
||||
await env.DB.batch(statements);
|
||||
|
||||
return normalizedFavoriteToolPaths;
|
||||
}
|
||||
|
||||
export async function onRequestGet({ request, env }: { request: Request; env: Env }) {
|
||||
const userId = getAuthenticatedUserId(request);
|
||||
|
||||
if (!userId) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const favoriteToolPaths = await readFavoriteToolPaths(env, userId);
|
||||
|
||||
return json({ favoriteToolPaths });
|
||||
}
|
||||
|
||||
export async function onRequestPut({ request, env }: { request: Request; env: Env }) {
|
||||
const userId = getAuthenticatedUserId(request);
|
||||
|
||||
if (!userId) {
|
||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const payload = await request.json().catch(() => null) as { favoriteToolPaths?: unknown } | null;
|
||||
const favoriteToolPaths = normalizeStoredFavoriteToolPaths(payload?.favoriteToolPaths);
|
||||
|
||||
await saveFavoriteToolPaths(env, userId, favoriteToolPaths);
|
||||
|
||||
return json({ favoriteToolPaths });
|
||||
}
|
||||
11
functions/api/me.ts
Normal file
11
functions/api/me.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { getAuthenticatedUserId } from '../_lib/favorites';
|
||||
|
||||
export async function onRequestGet({ request }: { request: Request }) {
|
||||
const userId = getAuthenticatedUserId(request);
|
||||
|
||||
if (!userId) {
|
||||
return Response.json({ authenticated: false }, { status: 401, headers: { 'cache-control': 'no-store' } });
|
||||
}
|
||||
|
||||
return Response.json({ authenticated: true, userId }, { headers: { 'cache-control': 'no-store' } });
|
||||
}
|
||||
11
migrations/0001_favorites.sql
Normal file
11
migrations/0001_favorites.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
user_id TEXT NOT NULL,
|
||||
tool_path TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (user_id, tool_path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_favorites_user_sort_order
|
||||
ON favorites (user_id, sort_order);
|
||||
1
public/_redirects
Normal file
1
public/_redirects
Normal file
|
|
@ -0,0 +1 @@
|
|||
/* /index.html 200
|
||||
17
shared/favorites.ts
Normal file
17
shared/favorites.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export function normalizeFavoriteToolPaths(paths: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const path of paths) {
|
||||
const value = path.trim();
|
||||
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
normalized.push(value);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
12
src/tools/favorites.storage.test.ts
Normal file
12
src/tools/favorites.storage.test.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { normalizeFavoriteToolPaths } from '../../shared/favorites';
|
||||
|
||||
describe('normalizeFavoriteToolPaths', () => {
|
||||
it('removes blanks and duplicates while preserving order', () => {
|
||||
expect(normalizeFavoriteToolPaths([' /alpha ', '', '/beta', '/alpha', ' ', '/gamma'])).toEqual([
|
||||
'/alpha',
|
||||
'/beta',
|
||||
'/gamma',
|
||||
]);
|
||||
});
|
||||
});
|
||||
40
src/tools/favorites.storage.ts
Normal file
40
src/tools/favorites.storage.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { normalizeFavoriteToolPaths } from '../../shared/favorites';
|
||||
|
||||
const FAVORITES_STORAGE_KEY = 'favoriteToolsName';
|
||||
|
||||
function hasLocalStorage() {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
|
||||
}
|
||||
|
||||
export function readCachedFavoriteToolPaths() {
|
||||
if (!hasLocalStorage()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rawValue = window.localStorage.getItem(FAVORITES_STORAGE_KEY);
|
||||
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedValue = JSON.parse(rawValue);
|
||||
|
||||
if (!Array.isArray(parsedValue)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return normalizeFavoriteToolPaths(parsedValue.filter((value): value is string => typeof value === 'string'));
|
||||
}
|
||||
catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCachedFavoriteToolPaths(paths: readonly string[]) {
|
||||
if (!hasLocalStorage()) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(FAVORITES_STORAGE_KEY, JSON.stringify(normalizeFavoriteToolPaths(paths)));
|
||||
}
|
||||
|
|
@ -1,14 +1,133 @@
|
|||
import { type MaybeRef, get, useStorage } from '@vueuse/core';
|
||||
import { type MaybeRef, get } from '@vueuse/core';
|
||||
import { defineStore } from 'pinia';
|
||||
import type { Ref } from 'vue';
|
||||
import _ from 'lodash';
|
||||
import { normalizeFavoriteToolPaths } from '../../shared/favorites';
|
||||
import type { Tool, ToolCategory, ToolWithCategory } from './tools.types';
|
||||
import { readCachedFavoriteToolPaths, writeCachedFavoriteToolPaths } from './favorites.storage';
|
||||
import { toolsWithCategory } from './index';
|
||||
|
||||
const FAVORITES_API_PATH = '/api/favorites';
|
||||
|
||||
const favoriteToolPaths = ref<string[]>([]);
|
||||
const favoriteSyncMode = ref<'loading' | 'remote' | 'local'>('loading');
|
||||
|
||||
let favoriteHydrationPromise: Promise<void> | null = null;
|
||||
let favoritePersistenceQueue = Promise.resolve();
|
||||
|
||||
async function fetchFavoriteToolPaths() {
|
||||
const response = await fetch(FAVORITES_API_PATH, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load favorites (${response.status})`);
|
||||
}
|
||||
|
||||
const payload = await response.json() as { favoriteToolPaths?: unknown };
|
||||
|
||||
if (!Array.isArray(payload.favoriteToolPaths)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return normalizeFavoriteToolPaths(payload.favoriteToolPaths.filter((value): value is string => typeof value === 'string'));
|
||||
}
|
||||
|
||||
async function saveFavoriteToolPaths(paths: string[]) {
|
||||
const response = await fetch(FAVORITES_API_PATH, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ favoriteToolPaths: normalizeFavoriteToolPaths(paths) }),
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save favorites (${response.status})`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function queueFavoritePersistence() {
|
||||
favoritePersistenceQueue = favoritePersistenceQueue.then(async () => {
|
||||
const currentFavoriteToolPaths = normalizeFavoriteToolPaths(favoriteToolPaths.value);
|
||||
|
||||
if (favoriteSyncMode.value !== 'remote') {
|
||||
writeCachedFavoriteToolPaths(currentFavoriteToolPaths);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const saved = await saveFavoriteToolPaths(currentFavoriteToolPaths);
|
||||
|
||||
if (!saved) {
|
||||
favoriteSyncMode.value = 'local';
|
||||
}
|
||||
}
|
||||
catch {
|
||||
favoriteSyncMode.value = 'local';
|
||||
}
|
||||
|
||||
writeCachedFavoriteToolPaths(currentFavoriteToolPaths);
|
||||
});
|
||||
|
||||
return favoritePersistenceQueue;
|
||||
}
|
||||
|
||||
async function hydrateFavoriteToolPaths() {
|
||||
if (favoriteHydrationPromise) {
|
||||
return favoriteHydrationPromise;
|
||||
}
|
||||
|
||||
favoriteHydrationPromise = (async () => {
|
||||
const cachedFavoriteToolPaths = readCachedFavoriteToolPaths();
|
||||
|
||||
try {
|
||||
const remoteFavoriteToolPaths = await fetchFavoriteToolPaths();
|
||||
|
||||
if (remoteFavoriteToolPaths === null) {
|
||||
favoriteToolPaths.value = cachedFavoriteToolPaths;
|
||||
favoriteSyncMode.value = 'local';
|
||||
return;
|
||||
}
|
||||
|
||||
if (remoteFavoriteToolPaths.length === 0 && cachedFavoriteToolPaths.length > 0) {
|
||||
favoriteToolPaths.value = cachedFavoriteToolPaths;
|
||||
favoriteSyncMode.value = 'remote';
|
||||
await saveFavoriteToolPaths(cachedFavoriteToolPaths);
|
||||
writeCachedFavoriteToolPaths(cachedFavoriteToolPaths);
|
||||
return;
|
||||
}
|
||||
|
||||
favoriteToolPaths.value = remoteFavoriteToolPaths;
|
||||
favoriteSyncMode.value = 'remote';
|
||||
writeCachedFavoriteToolPaths(remoteFavoriteToolPaths);
|
||||
}
|
||||
catch {
|
||||
favoriteToolPaths.value = cachedFavoriteToolPaths;
|
||||
favoriteSyncMode.value = 'local';
|
||||
}
|
||||
})();
|
||||
|
||||
return favoriteHydrationPromise;
|
||||
}
|
||||
|
||||
export const useToolStore = defineStore('tools', () => {
|
||||
const favoriteToolsName = useStorage('favoriteToolsName', []) as Ref<string[]>;
|
||||
const { t } = useI18n();
|
||||
|
||||
void hydrateFavoriteToolPaths();
|
||||
|
||||
const tools = computed<ToolWithCategory[]>(() => toolsWithCategory.map((tool) => {
|
||||
const toolI18nKey = tool.path.replace(/\//g, '');
|
||||
|
||||
|
|
@ -33,8 +152,8 @@ export const useToolStore = defineStore('tools', () => {
|
|||
});
|
||||
|
||||
const favoriteTools = computed(() => {
|
||||
return favoriteToolsName.value
|
||||
.map(favoriteName => tools.value.find(({ name, path }) => name === favoriteName || path === favoriteName))
|
||||
return favoriteToolPaths.value
|
||||
.map(favoriteName => tools.value.find(({ path }) => path === favoriteName))
|
||||
.filter(Boolean) as ToolWithCategory[]; // cast because .filter(Boolean) does not remove undefined from type
|
||||
});
|
||||
|
||||
|
|
@ -44,24 +163,27 @@ export const useToolStore = defineStore('tools', () => {
|
|||
toolsByCategory,
|
||||
newTools: computed(() => tools.value.filter(({ isNew }) => isNew)),
|
||||
|
||||
addToolToFavorites({ tool }: { tool: MaybeRef<Tool> }) {
|
||||
async addToolToFavorites({ tool }: { tool: MaybeRef<Tool> }) {
|
||||
const toolPath = get(tool).path;
|
||||
|
||||
if (toolPath) {
|
||||
favoriteToolsName.value.push(toolPath);
|
||||
favoriteToolPaths.value = normalizeFavoriteToolPaths([...favoriteToolPaths.value, toolPath]);
|
||||
await queueFavoritePersistence();
|
||||
}
|
||||
},
|
||||
|
||||
removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) {
|
||||
favoriteToolsName.value = favoriteToolsName.value.filter(name => get(tool).name !== name && get(tool).path !== name);
|
||||
async removeToolFromFavorites({ tool }: { tool: MaybeRef<Tool> }) {
|
||||
favoriteToolPaths.value = favoriteToolPaths.value.filter(name => name !== get(tool).path);
|
||||
await queueFavoritePersistence();
|
||||
},
|
||||
|
||||
isToolFavorite({ tool }: { tool: MaybeRef<Tool> }) {
|
||||
return favoriteToolsName.value.includes(get(tool).name)
|
||||
|| favoriteToolsName.value.includes(get(tool).path);
|
||||
return favoriteToolPaths.value.includes(get(tool).path);
|
||||
},
|
||||
|
||||
updateFavoriteTools(newOrder: ToolWithCategory[]) {
|
||||
favoriteToolsName.value = newOrder.map(tool => tool.path);
|
||||
async updateFavoriteTools(newOrder: ToolWithCategory[]) {
|
||||
favoriteToolPaths.value = normalizeFavoriteToolPaths(newOrder.map(tool => tool.path));
|
||||
await queueFavoritePersistence();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
8
wrangler.toml
Normal file
8
wrangler.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
name = "it-tools"
|
||||
compatibility_date = "2026-06-22"
|
||||
pages_build_output_dir = "dist"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "it-tools-favorites"
|
||||
database_id = "771ebf21-67b4-481a-a542-437c7ed64a87"
|
||||
Loading…
Add table
Add a link
Reference in a new issue