mirror of
https://github.com/usetrmnl/inker.git
synced 2026-04-29 13:45:07 -07:00
0.3.1 — firmware 1.7.8 connectivity & display fixes
This commit is contained in:
@@ -211,7 +211,7 @@ export class ApiController {
|
||||
// Parse RSSI to integer
|
||||
const wifi = rssiStr ? parseInt(rssiStr, 10) : undefined;
|
||||
|
||||
this.logger.debug(`[DISPLAY] Extracted deviceApiKey: ${deviceApiKey}, battery: ${battery}%, wifi: ${wifi} dBm, fw: ${firmwareVersion}`);
|
||||
this.logger.debug(`[DISPLAY] Extracted deviceApiKey: ${deviceApiKey}, battery: ${batteryVoltageStr}V → ${battery}%, wifi: ${wifi} dBm, fw: ${firmwareVersion}`);
|
||||
|
||||
if (!deviceApiKey) {
|
||||
this.logger.error(`[DISPLAY] Missing HTTP_ID header. All headers: ${this.sanitizeHeaders(headers)}`);
|
||||
@@ -497,7 +497,7 @@ export class ApiController {
|
||||
* Using linear approximation for simplicity
|
||||
*/
|
||||
private voltageToPercentage(voltage: number): number {
|
||||
const minVoltage = 3.5; // 0% battery - device low-voltage cutoff
|
||||
const minVoltage = 3.0; // 0% battery - LiPo low-voltage cutoff
|
||||
const maxVoltage = 4.2; // 100% battery
|
||||
|
||||
if (voltage >= maxVoltage) return 100;
|
||||
|
||||
@@ -95,8 +95,8 @@ export class DefaultScreenService implements OnModuleInit {
|
||||
const welcomeConfig = await this.settingsService.getWelcomeScreenConfig();
|
||||
const svg = this.createDefaultScreenSvg(width, height, welcomeConfig.title, welcomeConfig.subtitle);
|
||||
|
||||
// Convert SVG to e-ink optimized 1-bit PNG (same pipeline as designed screens)
|
||||
// TRMNL OG firmware requires dithered, negated, 1-bit palette PNG
|
||||
// Convert SVG to e-ink optimized grayscale PNG (same pipeline as designed screens)
|
||||
// Standard 8-bit grayscale — firmware handles display color mapping
|
||||
const grayBuffer = await sharp(Buffer.from(svg))
|
||||
.grayscale()
|
||||
.normalise()
|
||||
@@ -110,8 +110,7 @@ export class DefaultScreenService implements OnModuleInit {
|
||||
await sharp(dithered, {
|
||||
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
|
||||
})
|
||||
.negate()
|
||||
.png({ compressionLevel: 9, palette: true, colours: 2 })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(this.defaultScreenPath);
|
||||
|
||||
this.logger.log(`Default screen saved to: ${this.defaultScreenPath}`);
|
||||
@@ -255,8 +254,7 @@ export class DefaultScreenService implements OnModuleInit {
|
||||
await sharp(dithered, {
|
||||
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
|
||||
})
|
||||
.negate()
|
||||
.png({ compressionLevel: 9, palette: true, colours: 2 })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(outputPath);
|
||||
|
||||
return outputPath;
|
||||
@@ -319,14 +317,12 @@ export class DefaultScreenService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default screen as a buffer for browser preview (un-negated)
|
||||
* The on-disk PNG is negated for e-ink devices, so we negate it back for display
|
||||
* Get the default screen as a buffer for browser preview
|
||||
*/
|
||||
async getDefaultScreenPreviewBuffer(): Promise<Buffer> {
|
||||
await this.ensureDefaultScreenExists();
|
||||
|
||||
return sharp(this.defaultScreenPath)
|
||||
.negate()
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
@@ -40,29 +40,25 @@ describe('DisplayService', () => {
|
||||
expect(getCurrentScreen(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('should default duration to 60 when duration is 0 (falsy)', () => {
|
||||
// duration 0 is falsy, so `item.duration || 60` defaults to 60
|
||||
it('should return Infinity remainingTime for single item (no rotation needed)', () => {
|
||||
const items = [{ id: 1, duration: 0 }];
|
||||
const result = getCurrentScreen(items);
|
||||
expect(result.item.id).toBe(1);
|
||||
expect(result.remainingTime).toBeGreaterThan(0);
|
||||
expect(result.remainingTime).toBeLessThanOrEqual(60);
|
||||
expect(result.remainingTime).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('should return single item with calculated remaining time', () => {
|
||||
it('should return single item with Infinity remainingTime', () => {
|
||||
const items = [{ id: 1, duration: 60 }];
|
||||
const result = getCurrentScreen(items);
|
||||
expect(result.item.id).toBe(1);
|
||||
expect(result.remainingTime).toBeGreaterThan(0);
|
||||
expect(result.remainingTime).toBeLessThanOrEqual(60);
|
||||
expect(result.remainingTime).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('should default duration to 60 when not set', () => {
|
||||
it('should return Infinity remainingTime when duration not set', () => {
|
||||
const items = [{ id: 1 }];
|
||||
const result = getCurrentScreen(items);
|
||||
expect(result.item.id).toBe(1);
|
||||
expect(result.remainingTime).toBeGreaterThan(0);
|
||||
expect(result.remainingTime).toBeLessThanOrEqual(60);
|
||||
expect(result.remainingTime).toBe(Infinity);
|
||||
});
|
||||
|
||||
it('should rotate through multiple screens based on time', () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { DefaultScreenService } from './default-screen.service';
|
||||
import { ScreenRendererService } from '../../screen-designer/services/screen-renderer.service';
|
||||
import { PluginsService } from '../../plugins/plugins.service';
|
||||
import { SetupService } from '../setup/setup.service';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -29,6 +30,7 @@ export class DisplayService {
|
||||
private defaultScreenService: DefaultScreenService,
|
||||
private screenRendererService: ScreenRendererService,
|
||||
private pluginsService: PluginsService,
|
||||
private setupService: SetupService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -51,7 +53,7 @@ export class DisplayService {
|
||||
const apiUrl = baseUrl || this.config.get<string>('api.url', 'http://localhost:3002');
|
||||
// Find device by MAC address (id header) or API key (access-token header)
|
||||
// The Ruby version looks up by MAC address for better compatibility
|
||||
const device = await this.prisma.device.findFirst({
|
||||
let device = await this.prisma.device.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ macAddress: macAddressOrApiKey },
|
||||
@@ -90,24 +92,64 @@ export class DisplayService {
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
// Return reset signal instead of 404 - tells device to factory reset
|
||||
// Device will clear its API key and return to setup mode
|
||||
// Include ALL expected fields so ArduinoJson on ESP32 doesn't fail parsing
|
||||
this.logger.log(`Device not found for key ${macAddressOrApiKey} - sending factory reset signal`);
|
||||
return {
|
||||
status: 0,
|
||||
image_url: '',
|
||||
filename: '',
|
||||
image_url_timeout: 0,
|
||||
firmware_url: '',
|
||||
update_firmware: false,
|
||||
refresh_rate: 0,
|
||||
reset_firmware: true,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
message: 'Device removed from server',
|
||||
};
|
||||
// Auto-provision unknown devices instead of factory resetting
|
||||
// This handles devices connecting to a new/rebuilt server that still have
|
||||
// a stored api_key — they skip /api/setup and call /api/display directly
|
||||
const macRegex = /^([0-9A-Fa-f]{2}[:-]?){5}([0-9A-Fa-f]{2})$/;
|
||||
const isBlocked = macRegex.test(macAddressOrApiKey) && await this.prisma.blockedDevice.findUnique({
|
||||
where: { macAddress: macAddressOrApiKey },
|
||||
});
|
||||
if (macRegex.test(macAddressOrApiKey) && !isBlocked) {
|
||||
this.logger.log(`Auto-provisioning unknown device with MAC ${macAddressOrApiKey}`);
|
||||
try {
|
||||
await this.setupService.provisionDevice(
|
||||
macAddressOrApiKey,
|
||||
firmwareVersion,
|
||||
metrics,
|
||||
baseUrl,
|
||||
);
|
||||
// Re-fetch the newly created device to continue with display logic
|
||||
device = await this.prisma.device.findFirst({
|
||||
where: { macAddress: macAddressOrApiKey },
|
||||
include: {
|
||||
model: true,
|
||||
playlist: {
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
screen: true,
|
||||
screenDesign: { include: { widgets: { include: { template: true } } } },
|
||||
pluginInstance: { include: { plugin: true } },
|
||||
},
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`Auto-provision failed for ${macAddressOrApiKey}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found after auto-provision attempt, send reset
|
||||
if (!device) {
|
||||
this.logger.log(`Device not found for key ${macAddressOrApiKey} - sending factory reset signal`);
|
||||
return {
|
||||
status: 0,
|
||||
image_url: '',
|
||||
filename: '',
|
||||
image_url_timeout: 0,
|
||||
firmware_url: '',
|
||||
update_firmware: false,
|
||||
refresh_rate: 0,
|
||||
reset_firmware: true,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
message: 'Device removed from server',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check if device has a pending refresh (playlist just changed)
|
||||
@@ -245,7 +287,8 @@ export class DisplayService {
|
||||
: null;
|
||||
|
||||
// Detect screen change for ghosting prevention (full refresh on e-ink)
|
||||
const screenChanged = currentScreenId && device.lastScreenId !== currentScreenId;
|
||||
// maximum_compatibility = true forces slower full refresh, preventing artifacts
|
||||
const screenChanged = !!(currentScreenId && device.lastScreenId !== currentScreenId);
|
||||
if (screenChanged) {
|
||||
this.logger.debug(
|
||||
`Screen changed for device ${device.name}: ${device.lastScreenId} -> ${currentScreenId} (will trigger full refresh)`,
|
||||
@@ -298,7 +341,7 @@ export class DisplayService {
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
maximum_compatibility: screenChanged,
|
||||
refresh_at: nextRefreshAt,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
@@ -341,7 +384,7 @@ export class DisplayService {
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
maximum_compatibility: screenChanged,
|
||||
refresh_at: nextRefreshAt,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
@@ -389,7 +432,7 @@ export class DisplayService {
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
maximum_compatibility: screenChanged,
|
||||
refresh_at: nextRefreshAt,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
@@ -418,7 +461,7 @@ export class DisplayService {
|
||||
reset_firmware: false,
|
||||
special_function: '',
|
||||
temperature_profile: 'default',
|
||||
maximum_compatibility: false,
|
||||
maximum_compatibility: screenChanged,
|
||||
refresh_at: nextRefreshAt,
|
||||
battery: updatedDevice.battery,
|
||||
wifi: updatedDevice.wifi,
|
||||
@@ -460,19 +503,10 @@ export class DisplayService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// SINGLE SCREEN: Respect duration for refresh timing
|
||||
// SINGLE SCREEN: No rotation needed — use device refresh rate
|
||||
// Dynamic widgets (clock, countdown) are handled by getRefreshRateForScreen()
|
||||
if (items.length === 1) {
|
||||
const itemDuration = items[0].duration || 60;
|
||||
|
||||
// Duration 0 = never refresh (static content, save battery)
|
||||
if (itemDuration === 0) {
|
||||
return { item: items[0], remainingTime: Infinity };
|
||||
}
|
||||
|
||||
// Calculate remaining time in current cycle based on duration
|
||||
const currentSecond = Math.floor(Date.now() / 1000);
|
||||
const remainingTime = itemDuration - (currentSecond % itemDuration);
|
||||
return { item: items[0], remainingTime };
|
||||
return { item: items[0], remainingTime: Infinity };
|
||||
}
|
||||
|
||||
// Multiple screens: rotation based on current time
|
||||
@@ -646,6 +680,11 @@ export class DisplayService {
|
||||
refreshRate = remainingTime;
|
||||
}
|
||||
|
||||
// Floor: never go below 10 seconds to prevent rapid polling from edge cases
|
||||
if (refreshRate < 10) {
|
||||
refreshRate = 10;
|
||||
}
|
||||
|
||||
return refreshRate;
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,7 @@ export class SetupScreenService implements OnModuleInit {
|
||||
await sharp(dithered, {
|
||||
raw: { width: grayBuffer.info.width, height: grayBuffer.info.height, channels: 1 },
|
||||
})
|
||||
.negate()
|
||||
.png({ compressionLevel: 9, palette: true, colours: 2 })
|
||||
.png({ compressionLevel: 9 })
|
||||
.toFile(this.setupScreenPath);
|
||||
|
||||
this.logger.log(`Setup screen saved to: ${this.setupScreenPath}`);
|
||||
|
||||
@@ -157,7 +157,7 @@ export class SetupService {
|
||||
const setupScreenUrl = this.setupScreenService.getSetupScreenUrl();
|
||||
|
||||
return {
|
||||
status: 200, // TRMNL firmware expects status field
|
||||
status: 200, // Firmware 1.7.8 setup parser checks status == 200
|
||||
api_key: device.apiKey, // CRITICAL: Must be 'api_key' not 'uuid'
|
||||
friendly_id: device.friendlyId, // Friendly name for the device
|
||||
image_url: `${apiUrl}${setupScreenUrl}`, // Setup screen image
|
||||
|
||||
@@ -677,7 +677,7 @@ export class ScreenDesignerController {
|
||||
output[i] = Math.max(0, Math.min(255, Math.round(pixels[i])));
|
||||
}
|
||||
|
||||
// Create 1-bit PNG using palette mode (black and white only)
|
||||
// Create grayscale PNG (dithered to black/white values)
|
||||
buffer = await sharp(output, {
|
||||
raw: {
|
||||
width: info.width,
|
||||
@@ -685,11 +685,7 @@ export class ScreenDesignerController {
|
||||
channels: 1,
|
||||
},
|
||||
})
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
palette: true,
|
||||
colours: 2, // 1-bit: 2 colors (black/white)
|
||||
})
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
|
||||
attempts++;
|
||||
|
||||
@@ -348,26 +348,16 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
// Apply Floyd-Steinberg dithering
|
||||
const ditheredBuffer = this.applyFloydSteinbergDithering(data, info.width, info.height, threshold);
|
||||
|
||||
// Create 1-bit PNG
|
||||
let sharpInstance = sharp(ditheredBuffer, {
|
||||
// Output as standard 8-bit grayscale PNG (no palette mode)
|
||||
// Firmware 1.7.8 handles display color mapping — palette PNGs cause scrambled display
|
||||
let buffer = await sharp(ditheredBuffer, {
|
||||
raw: {
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
channels: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// Apply negate for device mode (TRMNL expects inverted colors)
|
||||
if (negate) {
|
||||
sharpInstance = sharpInstance.negate();
|
||||
}
|
||||
|
||||
let buffer = await sharpInstance
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
palette: true,
|
||||
colours: 2,
|
||||
})
|
||||
})
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
|
||||
// If still too large, scale down and re-dither
|
||||
@@ -399,29 +389,19 @@ export class ScreenRendererService implements OnModuleDestroy, OnModuleInit {
|
||||
threshold,
|
||||
);
|
||||
|
||||
let scaledSharp = sharp(scaledDithered, {
|
||||
buffer = await sharp(scaledDithered, {
|
||||
raw: {
|
||||
width: scaledGray.info.width,
|
||||
height: scaledGray.info.height,
|
||||
channels: 1,
|
||||
},
|
||||
});
|
||||
|
||||
if (negate) {
|
||||
scaledSharp = scaledSharp.negate();
|
||||
}
|
||||
|
||||
buffer = await scaledSharp
|
||||
.png({
|
||||
compressionLevel: 9,
|
||||
palette: true,
|
||||
colours: 2,
|
||||
})
|
||||
})
|
||||
.png({ compressionLevel: 9 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`E-ink processing complete: ${buffer.length} bytes, 1-bit, negate=${negate}`,
|
||||
`E-ink processing complete: ${buffer.length} bytes, grayscale dithered`,
|
||||
);
|
||||
|
||||
return buffer;
|
||||
|
||||
@@ -102,6 +102,34 @@ server {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# Device API endpoints — strip security headers for ESP32 devices
|
||||
# (ESP32 has ~8-16KB HTTP buffer; Helmet headers can overflow it)
|
||||
location ~ ^/api/(setup|display|log)(/?)$ {
|
||||
proxy_pass http://127.0.0.1:3002;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $real_scheme;
|
||||
|
||||
proxy_hide_header Content-Security-Policy;
|
||||
proxy_hide_header Strict-Transport-Security;
|
||||
proxy_hide_header X-Content-Type-Options;
|
||||
proxy_hide_header X-DNS-Prefetch-Control;
|
||||
proxy_hide_header X-Frame-Options;
|
||||
proxy_hide_header X-XSS-Protection;
|
||||
proxy_hide_header Cross-Origin-Opener-Policy;
|
||||
proxy_hide_header Cross-Origin-Resource-Policy;
|
||||
proxy_hide_header Origin-Agent-Cluster;
|
||||
proxy_hide_header X-Download-Options;
|
||||
proxy_hide_header X-Permitted-Cross-Domain-Policies;
|
||||
proxy_hide_header Referrer-Policy;
|
||||
proxy_hide_header Permissions-Policy;
|
||||
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3002/api/;
|
||||
|
||||
@@ -1,2 +1,9 @@
|
||||
#!/command/with-contenv sh
|
||||
# Wait for backend to be ready before accepting connections
|
||||
# Prevents 502 errors when devices connect during startup
|
||||
echo "[nginx] Waiting for backend..."
|
||||
until bun -e "const r=await fetch('http://127.0.0.1:3002/health');process.exit(r.ok?0:1)" 2>/dev/null; do
|
||||
sleep 1
|
||||
done
|
||||
echo "[nginx] Backend is ready, starting nginx"
|
||||
exec nginx -g "daemon off;"
|
||||
|
||||
@@ -1504,7 +1504,7 @@ export function CustomWidgetForm() {
|
||||
</code>
|
||||
<div className="flex items-center gap-1">
|
||||
{field.isImageUrl && (
|
||||
<span className="text-pink-500 text-xs">🖼️</span>
|
||||
<span className="text-pink-500 text-xs font-bold">IMG</span>
|
||||
)}
|
||||
{field.isLink && !field.isImageUrl && (
|
||||
<span className="text-accent text-xs">🔗</span>
|
||||
|
||||
@@ -103,28 +103,6 @@ export function AddDevice() {
|
||||
</h2>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* API URL Display */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||
Server URL for your device
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<code className="flex-1 text-sm bg-bg-muted px-4 py-3 rounded-lg border border-border-default font-mono">
|
||||
{deviceApiUrl}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-semibold text-white rounded-xl transition-all duration-200"
|
||||
style={{ backgroundColor: copied ? '#22c55e' : '#3b82f6' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = copied ? '#16a34a' : '#2563eb'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = copied ? '#22c55e' : '#3b82f6'; }}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Warning */}
|
||||
{!status.isOnline && (
|
||||
<div className="p-4 bg-status-warning-bg border border-status-warning-border rounded-lg">
|
||||
@@ -160,18 +138,33 @@ export function AddDevice() {
|
||||
<code className="bg-bg-muted px-1 rounded">192.168.4.1</code> in your browser.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Enter the server URL</strong>
|
||||
<p className="ml-6 mt-1 text-text-muted">
|
||||
Paste: <code className="bg-bg-muted px-1 rounded">{deviceApiUrl}</code>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Enter your WiFi credentials</strong>
|
||||
<p className="ml-6 mt-1 text-text-muted">
|
||||
Select your home/office WiFi and enter the password.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Set the Server URL in Advanced Settings</strong>
|
||||
<p className="ml-6 mt-1 text-text-muted">
|
||||
Open <strong>Advanced Settings</strong> in the captive portal and paste this URL into the <strong>API Server</strong> field:
|
||||
</p>
|
||||
<div className="ml-6 mt-2 flex items-center space-x-2">
|
||||
<code className="flex-1 text-sm bg-bg-muted px-4 py-3 rounded-lg border border-border-default font-mono">
|
||||
{deviceApiUrl}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-semibold text-white rounded-xl transition-all duration-200"
|
||||
style={{ backgroundColor: copied ? '#22c55e' : '#3b82f6' }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = copied ? '#16a34a' : '#2563eb'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = copied ? '#22c55e' : '#3b82f6'; }}
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Done!</strong>
|
||||
<p className="ml-6 mt-1 text-text-muted">
|
||||
|
||||
@@ -22,12 +22,6 @@ const CUSTOM_WIDGET_TEMPLATE_OFFSET = 10000;
|
||||
const RESOLUTION_PRESETS = [
|
||||
{ label: 'TRMNL Standard', width: 800, height: 480, description: '800 x 480 px (landscape)' },
|
||||
{ label: 'TRMNL Portrait', width: 480, height: 800, description: '480 x 800 px (portrait)' },
|
||||
{ label: 'Small Display', width: 400, height: 300, description: '400 x 300 px' },
|
||||
{ label: 'Medium Display', width: 640, height: 384, description: '640 x 384 px' },
|
||||
{ label: 'Large Display', width: 1024, height: 758, description: '1024 x 758 px' },
|
||||
{ label: 'E-Paper 2.9"', width: 296, height: 128, description: '296 x 128 px' },
|
||||
{ label: 'E-Paper 4.2"', width: 400, height: 300, description: '400 x 300 px' },
|
||||
{ label: 'E-Paper 7.5"', width: 800, height: 480, description: '800 x 480 px' },
|
||||
];
|
||||
|
||||
export function ScreenDesigner() {
|
||||
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 382 KiB |
Reference in New Issue
Block a user