[WIP] A simple wake-on-lan service
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

apply theme to target and add support for custom fonts

+199 -34
+50 -20
src/config.rs
··· 22 22 pub theme: Theme, 23 23 } 24 24 25 + type Colour = (u8, u8, u8); 26 + 25 27 /// all colours are in rgb 26 28 #[derive(Deserialize, Serialize, Debug, Clone)] 27 29 pub struct Theme { 28 30 #[serde(default = "Theme::default_background")] 29 - pub background: (u8, u8, u8), 30 - #[serde(default = "Theme::default_foreground")] 31 - pub foreground: (u8, u8, u8), 31 + pub background: Colour, 32 + #[serde(default = "Theme::default_background_main")] 33 + pub background_main: Colour, 34 + #[serde(default = "Theme::default_background_server")] 35 + pub background_server: Colour, 36 + #[serde(default = "Theme::default_background_button")] 37 + pub background_button: Colour, 38 + 32 39 #[serde(default = "Theme::default_text")] 33 - pub text: (u8, u8, u8), 40 + pub text: Colour, 34 41 #[serde(default = "Theme::default_text_secondary")] 35 - pub text_secondary: (u8, u8, u8), 42 + pub text_secondary: Colour, 43 + 36 44 #[serde(default = "Theme::default_accent_success")] 37 - pub accent_success: (u8, u8, u8), 45 + pub accent_success: Colour, 38 46 #[serde(default = "Theme::default_accent_fail")] 39 - pub accent_fail: (u8, u8, u8), 47 + pub accent_fail: Colour, 48 + 40 49 #[serde(default = "Theme::default_link")] 41 - pub link: (u8, u8, u8), 50 + pub link: Colour, 42 51 #[serde(default = "Theme::default_link_visited")] 43 - pub link_visited: (u8, u8, u8), 52 + pub link_visited: Colour, 53 + 44 54 #[serde(default = "Theme::default_highlight")] 45 - pub highlight: (u8, u8, u8), 55 + pub highlight: Colour, 46 56 #[serde(default = "Theme::default_highlight_opacity")] 47 57 pub highlight_opacity: u8, 58 + 59 + #[serde(default = "Theme::default_fonts")] 60 + pub fonts: Vec<String>, 48 61 } 49 62 50 63 #[derive(Deserialize, Serialize, Debug, Clone)] ··· 124 137 } 125 138 126 139 impl Theme { 127 - fn default_background() -> (u8, u8, u8) { 140 + fn default_background() -> Colour { 128 141 (48, 52, 70) 129 142 } 130 - fn default_foreground() -> (u8, u8, u8) { 143 + fn default_background_main() -> Colour { 131 144 (35, 38, 52) 132 145 } 133 - fn default_text() -> (u8, u8, u8) { 146 + fn default_background_server() -> Colour { 147 + (65, 69, 89) 148 + } 149 + fn default_background_button() -> Colour { 150 + (81, 87, 109) 151 + } 152 + 153 + fn default_text() -> Colour { 134 154 (198, 208, 245) 135 155 } 136 - fn default_text_secondary() -> (u8, u8, u8) { 156 + fn default_text_secondary() -> Colour { 137 157 (165, 173, 206) 138 158 } 139 - fn default_accent_success() -> (u8, u8, u8) { 159 + 160 + fn default_accent_success() -> Colour { 140 161 (166, 209, 137) 141 162 } 142 - fn default_accent_fail() -> (u8, u8, u8) { 163 + fn default_accent_fail() -> Colour { 143 164 (231, 130, 132) 144 165 } 145 - fn default_link() -> (u8, u8, u8) { 166 + 167 + fn default_link() -> Colour { 146 168 (140, 170, 238) 147 169 } 148 - fn default_link_visited() -> (u8, u8, u8) { 170 + fn default_link_visited() -> Colour { 149 171 (202, 158, 230) 150 172 } 151 - fn default_highlight() -> (u8, u8, u8) { 173 + 174 + fn default_highlight() -> Colour { 152 175 (148, 156, 187) 153 176 } 154 177 fn default_highlight_opacity() -> u8 { 155 178 25 156 179 } 180 + 181 + fn default_fonts() -> Vec<String> { 182 + vec![String::from("system-ui")] 183 + } 157 184 } 158 185 159 186 impl Default for Theme { 160 187 fn default() -> Self { 161 188 Self { 162 189 background: Theme::default_background(), 163 - foreground: Theme::default_foreground(), 190 + background_main: Theme::default_background_main(), 191 + background_server: Theme::default_background_server(), 192 + background_button: Theme::default_background_button(), 164 193 text: Theme::default_text(), 165 194 text_secondary: Theme::default_text_secondary(), 166 195 accent_success: Theme::default_accent_success(), ··· 169 198 link_visited: Theme::default_link_visited(), 170 199 highlight: Theme::default_highlight(), 171 200 highlight_opacity: Theme::default_highlight_opacity(), 201 + fonts: Theme::default_fonts(), 172 202 } 173 203 } 174 204 }
+1
web/package.json
··· 20 20 "vite": "^7.3.1" 21 21 }, 22 22 "dependencies": { 23 + "@lucide/svelte": "^0.577.0", 23 24 "zod": "^4.3.6" 24 25 } 25 26 }
+12
web/pnpm-lock.yaml
··· 8 8 9 9 .: 10 10 dependencies: 11 + '@lucide/svelte': 12 + specifier: ^0.577.0 13 + version: 0.577.0(svelte@5.53.0) 11 14 zod: 12 15 specifier: ^4.3.6 13 16 version: 4.3.6 ··· 207 210 208 211 '@jridgewell/trace-mapping@0.3.31': 209 212 resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 213 + 214 + '@lucide/svelte@0.577.0': 215 + resolution: {integrity: sha512-0P6mkySd2MapIEgq08tADPmcN4DHndC/02PWwaLkOerXlx5Sv9aT4BxyXLIY+eccr0g/nEyCYiJesqS61YdBZQ==} 216 + peerDependencies: 217 + svelte: ^5 210 218 211 219 '@rollup/rollup-android-arm-eabi@4.58.0': 212 220 resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} ··· 656 664 dependencies: 657 665 '@jridgewell/resolve-uri': 3.1.2 658 666 '@jridgewell/sourcemap-codec': 1.5.5 667 + 668 + '@lucide/svelte@0.577.0(svelte@5.53.0)': 669 + dependencies: 670 + svelte: 5.53.0 659 671 660 672 '@rollup/rollup-android-arm-eabi@4.58.0': 661 673 optional: true
+1 -1
web/src/App.svelte
··· 40 40 max-width: 50ch; 41 41 margin: 1rem auto; 42 42 padding: 1rem; 43 - background: var(--theme-foreground); 43 + background: var(--theme-background-main); 44 44 color: var(--theme-text); 45 45 border-radius: 10px; 46 46 }
+118 -8
web/src/lib/Target.svelte
··· 1 1 <script lang="ts"> 2 + import { ExternalLink, Power } from "@lucide/svelte"; 2 3 import Api from "./api"; 3 4 4 5 const { ··· 16 17 let online: boolean | undefined = $state(undefined); 17 18 const checkStatus: () => Promise<void> = async () => { 18 19 if (!ip) return; 19 - const active = await Api.ping({ ip }); 20 + const active = await Api.ping({ ip }).catch(() => false); 20 21 online = active; 21 22 // check every 30s if online, or every 5 seconds if offline 22 23 setTimeout(checkStatus, (active ? 30 : 5) * 1000); 23 24 }; 24 25 checkStatus(); 26 + 27 + // try clean up the url but dont lose sleep 28 + function cleanUrl(url: string): string { 29 + const REGEX = 30 + /(?<=^(?:[a-z]*:\/\/)?)(?:[a-z]+(?:\.[a-z]+)+|localhost)(?::\d{1,5})?(?:\/+[^\/]+)*/gm; 31 + const res = url.match(REGEX); 32 + if (res && res[0]) return res[0]; 33 + return url; 34 + } 25 35 </script> 26 36 27 37 <section> 28 38 <button 39 + class="power" 29 40 onclick={() => 30 41 Api.wake({ mac }).then((res) => alert(`Wake: ${mac} (${ip}) ${res}`))} 42 + aria-label="power" 31 43 > 32 - Power On 44 + <Power /> 33 45 </button> 34 46 35 47 <div class="name"> 36 - {name} <span class="status" data-status={online}></span> 48 + {name} 49 + {#if ip}<span class="status" data-status={online}></span>{/if} 50 + {#if url}<a href={url} class="url" title={url}> 51 + <span class="url-text">{cleanUrl(url)}</span> 52 + <ExternalLink /> 53 + </a>{/if} 37 54 </div> 38 55 <div class="mac">{mac}</div> 39 - {#if ip}<div class="ip"><a href={`http://${ip}/`}>{ip}</a></div>{/if} 40 - {#if url}<div class="url"><a href={url}>{url}</a></div>{/if} 56 + {#if ip} 57 + <div class="at">@</div> 58 + <div class="ip">{ip}</div> 59 + {/if} 41 60 </section> 42 61 43 62 <style> ··· 45 64 display: inline-block; 46 65 width: 0.5em; 47 66 height: 0.5em; 67 + border-radius: 100%; 68 + transition: background 500ms ease-in-out; 48 69 49 70 &[data-status="true"] { 50 - background: green; 71 + background: var(--theme-accent-success); 51 72 } 52 73 &[data-status="false"] { 53 - background: red; 74 + background: var(--theme-accent-fail); 54 75 } 55 76 } 56 77 57 78 section { 58 - border: 1px solid red; 79 + border-radius: 10px; 80 + background-color: var(--theme-background-server); 81 + 82 + box-sizing: border-box; 83 + padding: 0.5rem; 84 + display: grid; 85 + grid-template: 86 + "power name name name" 1lh 87 + "power mac at ip" 1lh 88 + / auto auto min-content auto; 89 + justify-content: start; 90 + align-items: center; 91 + gap: 0 5px; 92 + 93 + .name { 94 + grid-area: name; 95 + .url { 96 + text-decoration: none; 97 + .url-text { 98 + text-decoration: underline; 99 + &:hover { 100 + text-decoration-style: dotted; 101 + } 102 + 103 + &:active { 104 + text-decoration: none; 105 + } 106 + } 107 + } 108 + } 109 + 110 + .mac { 111 + grid-area: mac; 112 + color: var(--theme-text-secondary); 113 + } 114 + 115 + .at { 116 + grid-area: at; 117 + color: var(--theme-text-secondary); 118 + } 119 + 120 + .ip { 121 + grid-area: ip; 122 + color: var(--theme-text-secondary); 123 + } 124 + } 125 + 126 + @property --power-overlay { 127 + syntax: "<color>"; 128 + inherits: false; 129 + initial-value: transparent; 130 + } 131 + 132 + .power { 133 + color: inherit; 134 + grid-area: power; 135 + aspect-ratio: 1; 136 + height: 100%; 137 + display: flex; 138 + justify-content: center; 139 + align-items: center; 140 + 141 + border-radius: 100%; 142 + border: 0px; 143 + background: linear-gradient(var(--power-overlay), var(--power-overlay)), 144 + linear-gradient( 145 + var(--theme-background-button), 146 + var(--theme-background-button) 147 + ); 148 + 149 + transition: 150 + scale 10ms, 151 + --power-overlay 100ms; 152 + 153 + &:hover { 154 + --power-overlay: rgb( 155 + from var(--theme-highlight) r g b / var(--theme-highlight-opacity) 156 + ); 157 + } 158 + 159 + &:active { 160 + scale: 0.9; 161 + } 162 + } 163 + 164 + :global { 165 + .lucide-external-link { 166 + width: 1em; 167 + height: 1em; 168 + } 59 169 } 60 170 </style>
+5 -1
web/src/lib/ThemeProvider.svelte
··· 5 5 6 6 const custom = { 7 7 "--theme-background": rgb(config.theme.background), 8 - "--theme-foreground": rgb(config.theme.foreground), 8 + "--theme-background-main": rgb(config.theme.background_main), 9 + "--theme-background-server": rgb(config.theme.background_server), 10 + "--theme-background-button": rgb(config.theme.background_button), 9 11 "--theme-text": rgb(config.theme.text), 10 12 "--theme-text-secondary": rgb(config.theme.text_secondary), 11 13 "--theme-accent-success": rgb(config.theme.accent_success), ··· 14 16 "--theme-link-visited": rgb(config.theme.link_visited), 15 17 "--theme-highlight": rgb(config.theme.highlight), 16 18 "--theme-highlight-opacity": `${config.theme.highlight_opacity / 100}`, 19 + "--theme-fonts": `${config.theme.fonts.map((x) => `'${x}'`).join(", ")}`, 17 20 }; 18 21 19 22 for (const [k, v] of Object.entries(custom)) ··· 33 36 34 37 body { 35 38 background-color: var(--theme-background); 39 + font-family: var(--theme-fonts); 36 40 } 37 41 38 42 a {
+4 -1
web/src/lib/api.ts
··· 68 68 binding: z.string(), 69 69 theme: z.object({ 70 70 background: colour, 71 - foreground: colour, 71 + background_main: colour, 72 + background_server: colour, 73 + background_button: colour, 72 74 text: colour, 73 75 text_secondary: colour, 74 76 accent_success: colour, ··· 77 79 link_visited: colour, 78 80 highlight: colour, 79 81 highlight_opacity: u8, 82 + fonts: z.array(z.string()), 80 83 }), 81 84 info: z.object({ 82 85 title: z.string(),
+8 -3
web/vite.config.ts
··· 1 - import { defineConfig } from 'vite' 2 - import { svelte } from '@sveltejs/vite-plugin-svelte' 1 + import { defineConfig } from "vite"; 2 + import { svelte } from "@sveltejs/vite-plugin-svelte"; 3 3 4 4 // https://vite.dev/config/ 5 5 export default defineConfig({ 6 6 plugins: [svelte()], 7 - }) 7 + server: { 8 + hmr: { 9 + clientPort: 3001, 10 + }, 11 + }, 12 + });