feat: pof show battery in react app

This commit is contained in:
KRZYSZTOF RUDNICKI 2025-09-02 15:19:30 +02:00
parent 20bf3f3c0a
commit e8660cdee8
14 changed files with 1990 additions and 0 deletions

4
TS/battery-status/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
.DS_Store
*.log

View File

@ -0,0 +1,19 @@
# Battery Status (React + TypeScript + Vite)
Shows current battery status (charging and percentage), plus time to full/empty when available.
## Run locally
1. Install deps
2. Start dev server
```bash
npm install
npm run dev
```
Then open the printed local URL (default http://localhost:5173).
Notes:
- The Battery Status API may be unavailable or disabled in some browsers for privacy reasons. In that case, the app will show a helpful message.
- On laptops it typically works in Chromium-based browsers; mobile support varies.

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Battery Status</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1643
TS/battery-status/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,23 @@
{
"name": "battery-status",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit",
"build": "vite build",
"preview": "vite preview --strictPort --port 5173"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"typescript": "^5.5.4",
"vite": "^5.4.1"
}
}

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="16" fill="#1f2937"/>
<rect x="18" y="35" width="60" height="30" rx="6" fill="#61dafb"/>
<rect x="78" y="42" width="8" height="16" rx="2" fill="#61dafb"/>
</svg>

After

Width:  |  Height:  |  Size: 265 B

View File

@ -0,0 +1,63 @@
import { useBattery } from './useBattery'
import { useBeforeUnload } from './useBeforeUnload'
export function App() {
const { supported, loading, level, charging, chargingTime, dischargingTime, error } = useBattery()
// Ask for confirmation when leaving the page (custom message may be ignored by some browsers)
useBeforeUnload(true, 'Are you sure you want to leave this page? Unsaved battery info will be lost.')
if (!supported) {
return (
<div className="app">
<h1>Battery Status</h1>
<p>Battery Status API is not supported by this browser.</p>
<p>Tip: On some desktop browsers, this API may be disabled for privacy reasons.</p>
</div>
)
}
return (
<div className="app">
<h1>Battery Status</h1>
{loading ? (
<p>Loading</p>
) : error ? (
<p className="error">{error}</p>
) : (
<div className="card">
<div className="row">
<span className="label">Charging:</span>
<span className="value" data-charging={charging}>{charging ? 'Yes' : 'No'}</span>
</div>
<div className="row">
<span className="label">Level:</span>
<span className="value">{Math.round(level * 100)}%</span>
</div>
{typeof chargingTime === 'number' && charging && (
<div className="row">
<span className="label">Time to full:</span>
<span className="value">{formatTime(chargingTime)}</span>
</div>
)}
{typeof dischargingTime === 'number' && !charging && (
<div className="row">
<span className="label">Time to empty:</span>
<span className="value">{formatTime(dischargingTime)}</span>
</div>
)}
</div>
)}
<footer>
<small>Powered by the Battery Status API (where available)</small>
</footer>
</div>
)
}
function formatTime(seconds: number) {
if (!isFinite(seconds) || seconds < 0) return 'N/A'
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h === 0) return `${m}m`
return `${h}h ${m}m`
}

View File

@ -0,0 +1,12 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import { App } from './App'
import './style.css'
const container = document.getElementById('root')!
const root = createRoot(container)
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@ -0,0 +1,53 @@
:root {
color-scheme: light dark;
--bg: #0b0c10;
--fg: #e0e0e0;
--muted: #9aa0a6;
--accent: #61dafb;
--card: #1f2937;
--ok: #10b981;
--warn: #f59e0b;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; }
body {
margin: 0;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, "Apple Color Emoji", "Segoe UI Emoji";
background: var(--bg);
color: var(--fg);
}
.app {
max-width: 560px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
h1 { font-size: 1.8rem; margin: 0 0 1rem; }
p { color: var(--muted); }
.card {
margin-top: 1rem;
background: var(--card);
border-radius: 12px;
padding: 1rem 1.25rem;
border: 1px solid #334155;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px dashed #334155;
}
.row:last-child { border-bottom: 0; }
.label { color: var(--muted); }
.value { font-weight: 600; }
.value[data-charging="true"] { color: var(--ok); }
.value[data-charging="false"] { color: var(--warn); }
footer { margin-top: 1.25rem; color: var(--muted); }

View File

@ -0,0 +1,104 @@
import { useEffect, useState } from 'react'
type BatteryManagerLike = {
charging: boolean
chargingTime: number
dischargingTime: number
level: number
addEventListener?: (type: string, listener: () => void) => void
removeEventListener?: (type: string, listener: () => void) => void
onchargingchange?: (() => void) | null
onlevelchange?: (() => void) | null
onchargingtimechange?: (() => void) | null
ondischargingtimechange?: (() => void) | null
}
declare global {
interface Navigator {
getBattery?: () => Promise<BatteryManagerLike>
}
}
export function useBattery() {
const [supported, setSupported] = useState<boolean>(true)
const [loading, setLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null)
const [state, setState] = useState<Omit<BatteryManagerLike, 'addEventListener' | 'removeEventListener'>>({
charging: false,
chargingTime: Infinity,
dischargingTime: Infinity,
level: 1
})
useEffect(() => {
let battery: BatteryManagerLike | null = null
let unsub: (() => void) | undefined
const init = async () => {
if (!navigator.getBattery) {
setSupported(false)
setLoading(false)
return
}
try {
battery = await navigator.getBattery!()
if (!battery) return
const sync = () =>
setState({
charging: battery!.charging,
chargingTime: battery!.chargingTime,
dischargingTime: battery!.dischargingTime,
level: battery!.level
})
sync()
const onChange = () => sync()
battery.addEventListener?.('chargingchange', onChange)
battery.addEventListener?.('levelchange', onChange)
battery.addEventListener?.('chargingtimechange', onChange)
battery.addEventListener?.('dischargingtimechange', onChange)
// Fallback for browsers without addEventListener on BatteryManager
if (!battery.addEventListener && 'onlevelchange' in battery) {
battery.onchargingchange = onChange
battery.onlevelchange = onChange
battery.onchargingtimechange = onChange
battery.ondischargingtimechange = onChange
}
unsub = () => {
battery?.removeEventListener?.('chargingchange', onChange)
battery?.removeEventListener?.('levelchange', onChange)
battery?.removeEventListener?.('chargingtimechange', onChange)
battery?.removeEventListener?.('dischargingtimechange', onChange)
if (!battery?.removeEventListener) {
if (battery) {
battery.onchargingchange = null
battery.onlevelchange = null
battery.onchargingtimechange = null
battery.ondischargingtimechange = null
}
}
}
setLoading(false)
} catch (e: any) {
setError(e?.message ?? 'Failed to read battery status')
setLoading(false)
}
}
init()
return () => {
unsub?.()
}
}, [])
return {
supported,
loading,
error,
...state
}
}

View File

@ -0,0 +1,19 @@
import { useEffect } from 'react'
/**
* Prompts the user with a confirmation dialog when attempting to close/refresh the page.
* Note: Most modern browsers ignore custom text and display a generic message.
*/
export function useBeforeUnload(when: boolean = true, message: string = '') {
useEffect(() => {
if (!when) return
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault()
// Setting returnValue is required for some browsers to trigger the prompt
e.returnValue = message
return message
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [when, message])
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": []
}
}

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.base.json",
"include": ["src"]
}

View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
open: false
}
})