use daisyui components

This commit is contained in:
Graham McIntire 2025-07-05 09:22:47 -05:00
parent 3789684d8c
commit 53dc32f8fc
No known key found for this signature in database
11 changed files with 1230 additions and 921 deletions

View file

@ -76,9 +76,41 @@ Hooks.APRSMap = MapAPRSMap;
Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook; Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook;
Hooks.ChartJSTempChart = { Hooks.ChartJSTempChart = {
mounted() { this.renderChart(); }, mounted() {
console.log('Temperature chart mounted');
// Initialize global chart instances map if it doesn't exist
if (!window.chartInstances) {
window.chartInstances = new Map();
}
// Register this chart instance
window.chartInstances.set(this.el.id, this);
this.renderChart();
// Listen for theme changes
this.themeChangeHandler = () => {
console.log('Temperature chart received themeChanged event');
if (this.chart) {
console.log('Re-rendering temperature chart');
this.renderChart();
}
};
window.addEventListener('themeChanged', this.themeChangeHandler);
},
updated() { this.renderChart(); }, updated() { this.renderChart(); },
destroyed() {
// Clean up event listener
if (this.themeChangeHandler) {
window.removeEventListener('themeChanged', this.themeChangeHandler);
}
// Remove from global chart instances
if (window.chartInstances) {
window.chartInstances.delete(this.el.id);
}
},
renderChart() { renderChart() {
console.log('Temperature chart renderChart called');
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy();
} }
@ -87,6 +119,8 @@ Hooks.ChartJSTempChart = {
const times = data.map(d => new Date(d.timestamp)); const times = data.map(d => new Date(d.timestamp));
const temps = data.map(d => d.temperature); const temps = data.map(d => d.temperature);
const dews = data.map(d => d.dew_point); const dews = data.map(d => d.dew_point);
const colors = getThemeColors();
console.log('Temperature chart colors:', colors);
const canvas = this.el.querySelector('canvas'); const canvas = this.el.querySelector('canvas');
if (!canvas) { if (!canvas) {
@ -121,7 +155,13 @@ Hooks.ChartJSTempChart = {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: 'Temperature & Dew Point (°F)' text: 'Temperature & Dew Point (°F)',
color: colors.text
},
legend: {
labels: {
color: colors.text
}
} }
}, },
scales: { scales: {
@ -135,13 +175,27 @@ Hooks.ChartJSTempChart = {
}, },
title: { title: {
display: true, display: true,
text: 'Time' text: 'Time',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
}, },
y: { y: {
title: { title: {
display: true, display: true,
text: '°F' text: '°F',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
} }
} }
@ -151,16 +205,50 @@ Hooks.ChartJSTempChart = {
}; };
Hooks.ChartJSHumidityChart = { Hooks.ChartJSHumidityChart = {
mounted() { this.renderChart(); }, mounted() {
console.log('Humidity chart mounted');
// Initialize global chart instances map if it doesn't exist
if (!window.chartInstances) {
window.chartInstances = new Map();
}
// Register this chart instance
window.chartInstances.set(this.el.id, this);
this.renderChart();
// Listen for theme changes
this.themeChangeHandler = () => {
console.log('Humidity chart received themeChanged event');
if (this.chart) {
console.log('Re-rendering humidity chart');
this.renderChart();
}
};
window.addEventListener('themeChanged', this.themeChangeHandler);
},
updated() { this.renderChart(); }, updated() { this.renderChart(); },
destroyed() {
// Clean up event listener
if (this.themeChangeHandler) {
window.removeEventListener('themeChanged', this.themeChangeHandler);
}
// Remove from global chart instances
if (window.chartInstances) {
window.chartInstances.delete(this.el.id);
}
},
renderChart() { renderChart() {
console.log('Humidity chart renderChart called');
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy();
} }
const data = JSON.parse(this.el.dataset.weatherHistory); const data = JSON.parse(this.el.dataset.weatherHistory);
const times = data.map(d => new Date(d.timestamp)); const times = data.map(d => new Date(d.timestamp));
const hums = data.map(d => d.humidity); const humidity = data.map(d => d.humidity);
const colors = getThemeColors();
console.log('Humidity chart colors:', colors);
const canvas = this.el.querySelector('canvas'); const canvas = this.el.querySelector('canvas');
if (!canvas) { if (!canvas) {
@ -174,9 +262,9 @@ Hooks.ChartJSHumidityChart = {
labels: times, labels: times,
datasets: [{ datasets: [{
label: 'Humidity (%)', label: 'Humidity (%)',
data: hums, data: humidity,
borderColor: 'blue', borderColor: 'green',
backgroundColor: 'rgba(0, 0, 255, 0.1)', backgroundColor: 'rgba(0, 255, 0, 0.1)',
tension: 0.1 tension: 0.1
}] }]
}, },
@ -186,7 +274,13 @@ Hooks.ChartJSHumidityChart = {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: 'Humidity (%)' text: 'Humidity (%)',
color: colors.text
},
legend: {
labels: {
color: colors.text
}
} }
}, },
scales: { scales: {
@ -200,13 +294,27 @@ Hooks.ChartJSHumidityChart = {
}, },
title: { title: {
display: true, display: true,
text: 'Time' text: 'Time',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
}, },
y: { y: {
title: { title: {
display: true, display: true,
text: '%' text: '%',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
} }
} }
@ -216,16 +324,50 @@ Hooks.ChartJSHumidityChart = {
}; };
Hooks.ChartJSPressureChart = { Hooks.ChartJSPressureChart = {
mounted() { this.renderChart(); }, mounted() {
console.log('Pressure chart mounted');
// Initialize global chart instances map if it doesn't exist
if (!window.chartInstances) {
window.chartInstances = new Map();
}
// Register this chart instance
window.chartInstances.set(this.el.id, this);
this.renderChart();
// Listen for theme changes
this.themeChangeHandler = () => {
console.log('Pressure chart received themeChanged event');
if (this.chart) {
console.log('Re-rendering pressure chart');
this.renderChart();
}
};
window.addEventListener('themeChanged', this.themeChangeHandler);
},
updated() { this.renderChart(); }, updated() { this.renderChart(); },
destroyed() {
// Clean up event listener
if (this.themeChangeHandler) {
window.removeEventListener('themeChanged', this.themeChangeHandler);
}
// Remove from global chart instances
if (window.chartInstances) {
window.chartInstances.delete(this.el.id);
}
},
renderChart() { renderChart() {
console.log('Pressure chart renderChart called');
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy();
} }
const data = JSON.parse(this.el.dataset.weatherHistory); const data = JSON.parse(this.el.dataset.weatherHistory);
const times = data.map(d => new Date(d.timestamp)); const times = data.map(d => new Date(d.timestamp));
const pressures = data.map(d => d.pressure); const pressure = data.map(d => d.pressure);
const colors = getThemeColors();
console.log('Pressure chart colors:', colors);
const canvas = this.el.querySelector('canvas'); const canvas = this.el.querySelector('canvas');
if (!canvas) { if (!canvas) {
@ -238,138 +380,8 @@ Hooks.ChartJSPressureChart = {
data: { data: {
labels: times, labels: times,
datasets: [{ datasets: [{
label: 'Pressure (hPa)', label: 'Pressure (mb)',
data: pressures, data: pressure,
borderColor: 'green',
backgroundColor: 'rgba(0, 128, 0, 0.1)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Pressure (hPa)'
}
},
scales: {
x: {
type: 'time',
time: {
displayFormats: {
hour: 'HH:mm',
minute: 'HH:mm'
}
},
title: {
display: true,
text: 'Time'
}
},
y: {
title: {
display: true,
text: 'hPa'
}
}
}
}
});
}
};
Hooks.ChartJSWindDirectionChart = {
mounted() { this.renderChart(); },
updated() { this.renderChart(); },
renderChart() {
if (this.chart) {
this.chart.destroy();
}
const data = JSON.parse(this.el.dataset.weatherHistory);
const times = data.map(d => new Date(d.timestamp));
const dirs = data.map(d => d.wind_direction);
const canvas = this.el.querySelector('canvas');
if (!canvas) {
console.error('Canvas element not found for wind direction chart');
return;
}
this.chart = new Chart(canvas, {
type: 'line',
data: {
labels: times,
datasets: [{
label: 'Wind Direction (°)',
data: dirs,
borderColor: 'orange',
backgroundColor: 'rgba(255, 165, 0, 0.1)',
tension: 0.1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Wind Direction (°)'
}
},
scales: {
x: {
type: 'time',
time: {
displayFormats: {
hour: 'HH:mm',
minute: 'HH:mm'
}
},
title: {
display: true,
text: 'Time'
}
},
y: {
title: {
display: true,
text: '°'
}
}
}
}
});
}
};
Hooks.ChartJSWindSpeedChart = {
mounted() { this.renderChart(); },
updated() { this.renderChart(); },
renderChart() {
if (this.chart) {
this.chart.destroy();
}
const data = JSON.parse(this.el.dataset.weatherHistory);
const times = data.map(d => new Date(d.timestamp));
const speeds = data.map(d => d.wind_speed);
const canvas = this.el.querySelector('canvas');
if (!canvas) {
console.error('Canvas element not found for wind speed chart');
return;
}
this.chart = new Chart(canvas, {
type: 'line',
data: {
labels: times,
datasets: [{
label: 'Wind Speed (mph)',
data: speeds,
borderColor: 'purple', borderColor: 'purple',
backgroundColor: 'rgba(128, 0, 128, 0.1)', backgroundColor: 'rgba(128, 0, 128, 0.1)',
tension: 0.1 tension: 0.1
@ -381,7 +393,13 @@ Hooks.ChartJSWindSpeedChart = {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: 'Wind Speed (mph)' text: 'Pressure (mb)',
color: colors.text
},
legend: {
labels: {
color: colors.text
}
} }
}, },
scales: { scales: {
@ -395,13 +413,156 @@ Hooks.ChartJSWindSpeedChart = {
}, },
title: { title: {
display: true, display: true,
text: 'Time' text: 'Time',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
}, },
y: { y: {
title: { title: {
display: true, display: true,
text: 'mph' text: 'mb',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
}
}
}
}
});
}
};
Hooks.ChartJSWindChart = {
mounted() {
console.log('Wind chart mounted');
// Initialize global chart instances map if it doesn't exist
if (!window.chartInstances) {
window.chartInstances = new Map();
}
// Register this chart instance
window.chartInstances.set(this.el.id, this);
this.renderChart();
// Listen for theme changes
this.themeChangeHandler = () => {
console.log('Wind chart received themeChanged event');
if (this.chart) {
console.log('Re-rendering wind chart');
this.renderChart();
}
};
window.addEventListener('themeChanged', this.themeChangeHandler);
},
updated() { this.renderChart(); },
destroyed() {
// Clean up event listener
if (this.themeChangeHandler) {
window.removeEventListener('themeChanged', this.themeChangeHandler);
}
// Remove from global chart instances
if (window.chartInstances) {
window.chartInstances.delete(this.el.id);
}
},
renderChart() {
console.log('Wind chart renderChart called');
if (this.chart) {
this.chart.destroy();
}
const data = JSON.parse(this.el.dataset.weatherHistory);
const times = data.map(d => new Date(d.timestamp));
const windSpeed = data.map(d => d.wind_speed);
const windGust = data.map(d => d.wind_gust);
const colors = getThemeColors();
console.log('Wind chart colors:', colors);
const canvas = this.el.querySelector('canvas');
if (!canvas) {
console.error('Canvas element not found for wind chart');
return;
}
this.chart = new Chart(canvas, {
type: 'line',
data: {
labels: times,
datasets: [
{
label: 'Wind Speed (mph)',
data: windSpeed,
borderColor: 'orange',
backgroundColor: 'rgba(255, 165, 0, 0.1)',
tension: 0.1
},
{
label: 'Wind Gust (mph)',
data: windGust,
borderColor: 'brown',
backgroundColor: 'rgba(165, 42, 42, 0.1)',
tension: 0.1
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: 'Wind Speed & Gust (mph)',
color: colors.text
},
legend: {
labels: {
color: colors.text
}
}
},
scales: {
x: {
type: 'time',
time: {
displayFormats: {
hour: 'HH:mm',
minute: 'HH:mm'
}
},
title: {
display: true,
text: 'Time',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
}
},
y: {
title: {
display: true,
text: 'mph',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
} }
} }
@ -411,9 +572,41 @@ Hooks.ChartJSWindSpeedChart = {
}; };
Hooks.ChartJSRainChart = { Hooks.ChartJSRainChart = {
mounted() { this.renderChart(); }, mounted() {
console.log('Rain chart mounted');
// Initialize global chart instances map if it doesn't exist
if (!window.chartInstances) {
window.chartInstances = new Map();
}
// Register this chart instance
window.chartInstances.set(this.el.id, this);
this.renderChart();
// Listen for theme changes
this.themeChangeHandler = () => {
console.log('Rain chart received themeChanged event');
if (this.chart) {
console.log('Re-rendering rain chart');
this.renderChart();
}
};
window.addEventListener('themeChanged', this.themeChangeHandler);
},
updated() { this.renderChart(); }, updated() { this.renderChart(); },
destroyed() {
// Clean up event listener
if (this.themeChangeHandler) {
window.removeEventListener('themeChanged', this.themeChangeHandler);
}
// Remove from global chart instances
if (window.chartInstances) {
window.chartInstances.delete(this.el.id);
}
},
renderChart() { renderChart() {
console.log('Rain chart renderChart called');
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy();
} }
@ -422,7 +615,9 @@ Hooks.ChartJSRainChart = {
const times = data.map(d => new Date(d.timestamp)); const times = data.map(d => new Date(d.timestamp));
const rain1h = data.map(d => d.rain_1h); const rain1h = data.map(d => d.rain_1h);
const rain24h = data.map(d => d.rain_24h); const rain24h = data.map(d => d.rain_24h);
const rainMid = data.map(d => d.rain_since_midnight); const rainSinceMidnight = data.map(d => d.rain_since_midnight);
const colors = getThemeColors();
console.log('Rain chart colors:', colors);
const canvas = this.el.querySelector('canvas'); const canvas = this.el.querySelector('canvas');
if (!canvas) { if (!canvas) {
@ -445,15 +640,15 @@ Hooks.ChartJSRainChart = {
{ {
label: 'Rain (24h)', label: 'Rain (24h)',
data: rain24h, data: rain24h,
borderColor: 'navy', borderColor: 'cyan',
backgroundColor: 'rgba(0, 0, 128, 0.1)', backgroundColor: 'rgba(0, 255, 255, 0.1)',
tension: 0.1 tension: 0.1
}, },
{ {
label: 'Rain (since midnight)', label: 'Rain (since midnight)',
data: rainMid, data: rainSinceMidnight,
borderColor: 'teal', borderColor: 'navy',
backgroundColor: 'rgba(0, 128, 128, 0.1)', backgroundColor: 'rgba(0, 0, 128, 0.1)',
tension: 0.1 tension: 0.1
} }
] ]
@ -464,7 +659,13 @@ Hooks.ChartJSRainChart = {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: 'Rain (inches)' text: 'Rainfall (inches)',
color: colors.text
},
legend: {
labels: {
color: colors.text
}
} }
}, },
scales: { scales: {
@ -478,13 +679,27 @@ Hooks.ChartJSRainChart = {
}, },
title: { title: {
display: true, display: true,
text: 'Time' text: 'Time',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
}, },
y: { y: {
title: { title: {
display: true, display: true,
text: 'in' text: 'inches',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
} }
} }
@ -494,16 +709,50 @@ Hooks.ChartJSRainChart = {
}; };
Hooks.ChartJSLuminosityChart = { Hooks.ChartJSLuminosityChart = {
mounted() { this.renderChart(); }, mounted() {
console.log('Luminosity chart mounted');
// Initialize global chart instances map if it doesn't exist
if (!window.chartInstances) {
window.chartInstances = new Map();
}
// Register this chart instance
window.chartInstances.set(this.el.id, this);
this.renderChart();
// Listen for theme changes
this.themeChangeHandler = () => {
console.log('Luminosity chart received themeChanged event');
if (this.chart) {
console.log('Re-rendering luminosity chart');
this.renderChart();
}
};
window.addEventListener('themeChanged', this.themeChangeHandler);
},
updated() { this.renderChart(); }, updated() { this.renderChart(); },
destroyed() {
// Clean up event listener
if (this.themeChangeHandler) {
window.removeEventListener('themeChanged', this.themeChangeHandler);
}
// Remove from global chart instances
if (window.chartInstances) {
window.chartInstances.delete(this.el.id);
}
},
renderChart() { renderChart() {
console.log('Luminosity chart renderChart called');
if (this.chart) { if (this.chart) {
this.chart.destroy(); this.chart.destroy();
} }
const data = JSON.parse(this.el.dataset.weatherHistory); const data = JSON.parse(this.el.dataset.weatherHistory);
const times = data.map(d => new Date(d.timestamp)); const times = data.map(d => new Date(d.timestamp));
const lum = data.map(d => d.luminosity); const luminosity = data.map(d => d.luminosity);
const colors = getThemeColors();
console.log('Luminosity chart colors:', colors);
const canvas = this.el.querySelector('canvas'); const canvas = this.el.querySelector('canvas');
if (!canvas) { if (!canvas) {
@ -517,9 +766,9 @@ Hooks.ChartJSLuminosityChart = {
labels: times, labels: times,
datasets: [{ datasets: [{
label: 'Luminosity', label: 'Luminosity',
data: lum, data: luminosity,
borderColor: 'gold', borderColor: 'yellow',
backgroundColor: 'rgba(255, 215, 0, 0.1)', backgroundColor: 'rgba(255, 255, 0, 0.1)',
tension: 0.1 tension: 0.1
}] }]
}, },
@ -529,7 +778,13 @@ Hooks.ChartJSLuminosityChart = {
plugins: { plugins: {
title: { title: {
display: true, display: true,
text: 'Luminosity' text: 'Luminosity',
color: colors.text
},
legend: {
labels: {
color: colors.text
}
} }
}, },
scales: { scales: {
@ -543,13 +798,27 @@ Hooks.ChartJSLuminosityChart = {
}, },
title: { title: {
display: true, display: true,
text: 'Time' text: 'Time',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
}, },
y: { y: {
title: { title: {
display: true, display: true,
text: '' text: 'Luminosity',
color: colors.text
},
ticks: {
color: colors.text
},
grid: {
color: colors.grid
} }
} }
} }
@ -558,6 +827,99 @@ Hooks.ChartJSLuminosityChart = {
} }
}; };
// Helper function to get theme-aware colors
const getThemeColors = () => {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
return {
text: isDark ? '#e5e7eb' : '#111827',
grid: isDark ? '#374151' : '#9ca3af',
background: isDark ? 'rgba(0, 0, 0, 0.1)' : 'rgba(255, 255, 255, 0.1)'
};
};
// Theme switching functionality
const theme = (() => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
return 'auto';
})();
const applyTheme = (theme) => {
const element = document.documentElement;
if (theme === 'auto') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
element.setAttribute('data-theme', 'dark');
} else {
element.setAttribute('data-theme', 'light');
}
} else {
element.setAttribute('data-theme', theme);
}
};
// Apply initial theme
applyTheme(theme);
window.localStorage.setItem('theme', theme);
// Global function to re-render all charts
window.reRenderAllCharts = () => {
console.log('Re-rendering all charts...');
// Store all chart instances globally so we can access them
if (!window.chartInstances) {
window.chartInstances = new Map();
}
// Re-render all stored chart instances
window.chartInstances.forEach((chartInstance, elementId) => {
console.log('Re-rendering chart:', elementId);
if (chartInstance && typeof chartInstance.renderChart === 'function') {
chartInstance.renderChart();
}
});
// Also dispatch a custom event that charts can listen to
console.log('Dispatching themeChanged event');
window.dispatchEvent(new CustomEvent('themeChanged'));
};
const handleThemeClick = (selectedTheme) => {
console.log('Theme changed to:', selectedTheme);
applyTheme(selectedTheme);
localStorage.setItem('theme', selectedTheme);
// Re-render all charts with new theme colors
setTimeout(() => {
console.log('Calling reRenderAllCharts after theme change');
window.reRenderAllCharts();
}, 100);
};
// Listen for system theme changes when auto is selected
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (localStorage.getItem('theme') === 'auto') {
applyTheme('auto');
// Re-render all charts with new theme colors
setTimeout(() => {
window.reRenderAllCharts();
}, 100);
}
});
// Add event listeners for theme switching
document.addEventListener('DOMContentLoaded', () => {
const themeButtons = document.querySelectorAll('[data-set-theme]');
themeButtons.forEach(button => {
button.addEventListener('click', () => {
const theme = button.getAttribute('data-set-theme');
handleThemeClick(theme);
});
});
});
let liveSocket = new LiveSocket("/live", Socket, { let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: { _csrf_token: csrfToken }, params: { _csrf_token: csrfToken },

View file

@ -444,19 +444,86 @@ defmodule AprsmeWeb.CoreComponents do
def header(assigns) do def header(assigns) do
~H""" ~H"""
<header class={["bg-white shadow sticky top-0 z-40", @class]}> <div class="navbar bg-base-200 shadow-lg">
<nav <div class="navbar-start">
class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 flex h-16 items-center justify-between" <a href="/" class="btn btn-ghost text-xl font-bold text-primary">
aria-label="Main" aprs.me
> </a>
<div class="flex items-center space-x-4"> </div>
<a href="/" class="text-xl font-bold text-blue-700 hover:text-blue-900 transition-colors"> <div class="navbar-center hidden lg:flex">
aprs.me
</a>
</div>
<.navigation variant={:horizontal} /> <.navigation variant={:horizontal} />
</nav> </div>
</header> <div class="navbar-end">
<.theme_selector />
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost lg:hidden">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</div>
<ul
tabindex="0"
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52"
>
<.navigation variant={:vertical} />
</ul>
</div>
</div>
</div>
"""
end
@doc """
Renders a theme selector dropdown.
"""
def theme_selector(assigns) do
~H"""
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
<svg class="w-5 h-5 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M12 2.25a.75.75 0 01.75.75v2.25a.75.75 0 01-1.5 0V3a.75.75 0 01.75-.75zM7.5 12a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM18.894 6.166a.75.75 0 00-1.06-1.06l-1.591 1.59a.75.75 0 101.06 1.061l1.591-1.59zM21.75 12a.75.75 0 01-.75.75h-2.25a.75.75 0 010-1.5H21a.75.75 0 01.75.75zM17.834 18.894a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 10-1.061 1.06l1.59 1.591zM12 18a.75.75 0 01.75.75V21a.75.75 0 01-1.5 0v-2.25A.75.75 0 0112 18zM7.758 17.303a.75.75 0 00-1.061-1.06l-1.591 1.59a.75.75 0 001.06 1.061l1.591-1.59zM6 12a.75.75 0 01-.75.75H3a.75.75 0 010-1.5h2.25A.75.75 0 016 12zM6.697 7.757a.75.75 0 001.06-1.06l-1.59-1.591a.75.75 0 00-1.061 1.06l1.59 1.591z" />
</svg>
</div>
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52">
<li>
<button data-set-theme="light" data-act-class="ACTIVECLASS" class="flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clip-rule="evenodd"
/>
</svg>
Light
</button>
</li>
<li>
<button data-set-theme="dark" data-act-class="ACTIVECLASS" class="flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
Dark
</button>
</li>
<li>
<button data-set-theme="auto" data-act-class="ACTIVECLASS" class="flex items-center gap-2">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path
fill-rule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg>
Auto
</button>
</li>
</ul>
</div>
""" """
end end
@ -682,19 +749,17 @@ defmodule AprsmeWeb.CoreComponents do
def navigation(assigns) do def navigation(assigns) do
~H""" ~H"""
<nav class={[ <%= if @variant == :horizontal do %>
"flex items-center space-x-6", <ul class="menu menu-horizontal px-1">
@variant == :vertical && "flex-col space-x-0 space-y-3 items-start", <li><a href="/" class="link link-hover">Home</a></li>
@class <li><a href="/api" class="link link-hover">API</a></li>
]}> <li><a href="/about" class="link link-hover">About</a></li>
<a href="/" class="text-gray-700 hover:text-blue-700 font-medium transition-colors">Home</a> </ul>
<a href="/api" class="text-gray-700 hover:text-blue-700 font-medium transition-colors"> <% else %>
API <li><a href="/" class="link link-hover">Home</a></li>
</a> <li><a href="/api" class="link link-hover">API</a></li>
<a href="/about" class="text-gray-700 hover:text-blue-700 font-medium transition-colors"> <li><a href="/about" class="link link-hover">About</a></li>
About <% end %>
</a>
</nav>
""" """
end end
end end

View file

@ -5,6 +5,6 @@ defmodule AprsmeWeb.Layouts do
embed_templates "layouts/*" embed_templates "layouts/*"
def body_class(_assigns) do def body_class(_assigns) do
["bg-white antialiased"] ["bg-base-100 antialiased"]
end end
end end

View file

@ -1,5 +1,5 @@
<.header /> <.header />
<main class="min-h-screen bg-white"> <main class="min-h-screen bg-base-100">
<div> <div>
<.flash kind={:info} title="Success!" flash={@flash} /> <.flash kind={:info} title="Success!" flash={@flash} />
<.flash kind={:error} title="Error!" flash={@flash} /> <.flash kind={:error} title="Error!" flash={@flash} />

View file

@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="light">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -1,69 +1,45 @@
<div class="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100"> <div class="container mx-auto max-w-2xl py-12">
<div class="max-w-4xl mx-auto px-4 py-12"> <div class="card bg-base-100 shadow-xl mb-8">
<div class="card-body prose max-w-none">
<!-- Main Content --> <h2 class="card-title text-3xl">Under heavy construction</h2>
<div class="bg-white rounded-2xl shadow-xl p-8 mb-8"> <p>
<div class="prose prose-lg max-w-none"> Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
<h2 class="text-3xl font-bold text-gray-900 mb-6">Under heavy construction</h2> </p>
<p class="text-gray-700 mb-6 leading-relaxed"> <p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.
</p> </p>
<div class="bg-base-200 p-6 rounded-lg mb-8">
<p class="text-gray-700 mb-8 leading-relaxed"> <h4 class="text-lg font-semibold mb-3">Built with Modern Technologies</h4>
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. <div class="flex flex-wrap gap-2">
</p> <span class="badge badge-primary">Elixir</span>
<span class="badge badge-success">Phoenix</span>
<div class="bg-gray-50 p-6 rounded-lg mb-8"> <span class="badge badge-secondary">LiveView</span>
<h4 class="text-lg font-semibold text-gray-900 mb-3">Built with Modern Technologies</h4> <span class="badge badge-warning">PostgreSQL</span>
<div class="flex flex-wrap gap-3"> <span class="badge badge-error">Tailwind CSS</span>
<span class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
Elixir
</span>
<span class="px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm font-medium">
Phoenix
</span>
<span class="px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm font-medium">
LiveView
</span>
<span class="px-3 py-1 bg-orange-100 text-orange-800 rounded-full text-sm font-medium">
PostgreSQL
</span>
<span class="px-3 py-1 bg-red-100 text-red-800 rounded-full text-sm font-medium">
Tailwind CSS
</span>
</div>
</div>
<h3 class="text-2xl font-semibold text-gray-900 mb-4">Join Our Community</h3>
<p class="text-gray-700 mb-6 leading-relaxed">
Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.
</p>
<div class="text-center mt-12">
<a
href="/"
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors duration-200"
>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m-6 3l6-3"
>
</path>
</svg>
Explore the Map
</a>
</div> </div>
</div> </div>
</div> <h3 class="text-2xl font-semibold mb-4">Join Our Community</h3>
<p>
<!-- Footer Section --> Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.
<div class="text-center text-gray-500">
<p class="text-sm">
© 2025 aprs.me. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p> </p>
<div class="text-center mt-12">
<a href="/" class="btn btn-primary btn-lg">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m-6 3l6-3"
/>
</svg>
Explore the Map
</a>
</div>
</div> </div>
</div> </div>
<div class="text-center text-base-content/60">
<p class="text-sm">
&copy; 2025 aprs.me. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
</div>
</div> </div>

View file

@ -213,48 +213,58 @@ defmodule AprsmeWeb.ApiDocsLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div class="container mx-auto max-w-6xl py-8">
<div class="mb-8"> <div class="mb-8">
<h1 class="text-4xl font-bold text-gray-900 mb-4">APRS.me API Documentation</h1> <h1 class="text-4xl font-bold mb-4">APRS.me API Documentation</h1>
<p class="text-lg text-gray-600"> <p class="text-lg opacity-70">
RESTful JSON API for accessing APRS packet data and station information. RESTful JSON API for accessing APRS packet data and station information.
</p> </p>
</div> </div>
<!-- API Overview --> <!-- API Overview -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 mb-8"> <div class="card bg-base-100 shadow-xl mb-8">
<div class="px-6 py-4 border-b border-gray-200"> <div class="card-body">
<h2 class="text-2xl font-semibold text-gray-900">Overview</h2> <h2 class="card-title text-2xl">Overview</h2>
</div> <p class="mb-4">
<div class="px-6 py-4"> The APRS.me API provides programmatic access to Amateur Radio APRS (Automatic Packet Reporting System)
<div class="prose max-w-none"> data. All API endpoints return JSON data and follow RESTful conventions.
<p class="text-gray-700 mb-4"> </p>
The APRS.me API provides programmatic access to Amateur Radio APRS (Automatic Packet Reporting System)
data. All API endpoints return JSON data and follow RESTful conventions.
</p>
<div class="grid md:grid-cols-2 gap-6 mt-6"> <div class="grid md:grid-cols-2 gap-6 mt-6">
<div class="bg-blue-50 p-4 rounded-lg"> <div class="bg-primary/10 p-4 rounded-lg">
<h3 class="font-semibold text-blue-900 mb-2">Base URL</h3> <h3 class="font-semibold mb-2">Base URL</h3>
<code class="text-sm bg-white px-2 py-1 rounded border"> <code class="text-sm bg-base-200 px-2 py-1 rounded">
https://aprs.me/api/v1 https://aprs.me/api/v1
</code> </code>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<h3 class="font-semibold text-green-900 mb-2">Content Type</h3>
<code class="text-sm bg-white px-2 py-1 rounded border">
application/json
</code>
</div>
</div> </div>
<div class="mt-6 p-4 bg-yellow-50 rounded-lg"> <div class="bg-success/10 p-4 rounded-lg">
<h3 class="font-semibold text-yellow-900 mb-2">Rate Limiting</h3> <h3 class="font-semibold mb-2">Content Type</h3>
<p class="text-yellow-800"> <code class="text-sm bg-base-200 px-2 py-1 rounded">
Currently no rate limiting is enforced, but please be respectful and avoid excessive requests. application/json
Rate limiting may be implemented in the future. </code>
</p> </div>
</div>
<div class="alert alert-warning mt-6">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
<div>
<h3 class="font-bold">Rate Limiting</h3>
<div class="text-xs">
Currently no rate limiting is enforced, but please be respectful and avoid excessive requests. Rate limiting may be implemented in the future.
</div>
</div> </div>
</div> </div>
</div> </div>
@ -263,66 +273,46 @@ defmodule AprsmeWeb.ApiDocsLive do
<!-- API Endpoints --> <!-- API Endpoints -->
<div class="space-y-8"> <div class="space-y-8">
<!-- Callsign Endpoint --> <!-- Callsign Endpoint -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <div class="card bg-base-100 shadow-xl">
<div class="px-6 py-4 border-b border-gray-200"> <div class="card-body">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">Get Latest Packet by Callsign</h2> <h2 class="card-title text-xl">Get Latest Packet by Callsign</h2>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> <span class="badge badge-success">GET</span>
GET
</span>
</div> </div>
</div>
<div class="px-6 py-4">
<div class="mb-4"> <div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-2">Endpoint</h3> <h3 class="text-lg font-medium mb-2">Endpoint</h3>
<div class="bg-gray-50 p-3 rounded-lg"> <div class="bg-base-200 p-3 rounded-lg">
<code class="text-sm">GET /api/v1/callsign/{"{callsign}"}</code> <code class="text-sm">GET /api/v1/callsign/{"{callsign}"}</code>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-2">Description</h3> <h3 class="text-lg font-medium mb-2">Description</h3>
<p class="text-gray-700"> <p>
Retrieves the most recent APRS packet for the specified callsign. The callsign can include Retrieves the most recent APRS packet for the specified callsign. The callsign can include
an SSID (e.g., N0CALL-9) or just the base callsign (e.g., N0CALL). an SSID (e.g., N0CALL-9) or just the base callsign (e.g., N0CALL).
</p> </p>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-2">Parameters</h3> <h3 class="text-lg font-medium mb-2">Parameters</h3>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="table table-zebra">
<thead class="bg-gray-50"> <thead>
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th>Parameter</th>
Parameter <th>Type</th>
</th> <th>Required</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th>Description</th>
Type
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Required
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900"> <td class="font-mono">callsign</td>
callsign <td>string</td>
</td> <td>Yes</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td>Amateur radio callsign with optional SSID (e.g., N0CALL or N0CALL-9)</td>
string
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
Yes
</td>
<td class="px-6 py-4 text-sm text-gray-900">
Amateur radio callsign with optional SSID (e.g., N0CALL or N0CALL-9)
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -330,18 +320,18 @@ defmodule AprsmeWeb.ApiDocsLive do
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-2">Example Request</h3> <h3 class="text-lg font-medium mb-2">Example Request</h3>
<div class="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto"> <div class="bg-neutral text-neutral-content p-4 rounded-lg overflow-x-auto">
<pre><code>curl -X GET "https://aprs.me/api/v1/callsign/N0CALL-9" \ <pre><code>curl -X GET "https://aprs.me/api/v1/callsign/N0CALL-9" \
-H "Accept: application/json"</code></pre> -H "Accept: application/json"</code></pre>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-2">Response Format</h3> <h3 class="text-lg font-medium mb-2">Response Format</h3>
<h4 class="text-md font-medium text-gray-800 mb-2">Success Response (200 OK)</h4> <h4 class="text-md font-medium mb-2">Success Response (200 OK)</h4>
<div class="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto mb-4"> <div class="bg-neutral text-neutral-content p-4 rounded-lg overflow-x-auto mb-4">
<pre><code><%= raw ~s|{ <pre><code><%= raw ~s|{
"data": { "data": {
"id": 12345, "id": 12345,
@ -383,16 +373,16 @@ defmodule AprsmeWeb.ApiDocsLive do
}| %></code></pre> }| %></code></pre>
</div> </div>
<h4 class="text-md font-medium text-gray-800 mb-2">Not Found Response (404)</h4> <h4 class="text-md font-medium mb-2">Not Found Response (404)</h4>
<div class="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto mb-4"> <div class="bg-neutral text-neutral-content p-4 rounded-lg overflow-x-auto mb-4">
<pre><code><%= raw ~s|{ <pre><code><%= raw ~s|{
"data": null, "data": null,
"message": "No packets found for callsign N0CALL-9" "message": "No packets found for callsign N0CALL-9"
}| %></code></pre> }| %></code></pre>
</div> </div>
<h4 class="text-md font-medium text-gray-800 mb-2">Error Response (400)</h4> <h4 class="text-md font-medium mb-2">Error Response (400)</h4>
<div class="bg-gray-900 text-white p-4 rounded-lg overflow-x-auto"> <div class="bg-neutral text-neutral-content p-4 rounded-lg overflow-x-auto">
<pre><code><%= raw ~s|{ <pre><code><%= raw ~s|{
"error": { "error": {
"message": "Invalid callsign format", "message": "Invalid callsign format",
@ -405,118 +395,73 @@ defmodule AprsmeWeb.ApiDocsLive do
</div> </div>
<!-- Response Fields Documentation --> <!-- Response Fields Documentation -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <div class="card bg-base-100 shadow-xl">
<div class="px-6 py-4 border-b border-gray-200"> <div class="card-body">
<h2 class="text-xl font-semibold text-gray-900">Response Fields</h2> <h2 class="card-title text-xl">Response Fields</h2>
</div>
<div class="px-6 py-4">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="table table-zebra">
<thead class="bg-gray-50"> <thead>
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th>Field</th>
Field <th>Type</th>
</th> <th>Description</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">id</td> <td class="font-mono">id</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">integer</td> <td>integer</td>
<td class="px-6 py-4 text-sm text-gray-900">Unique packet identifier</td> <td>Unique packet identifier</td>
</tr>
<tr class="bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
callsign
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">string</td>
<td class="px-6 py-4 text-sm text-gray-900">
Full callsign with SSID (e.g., "N0CALL-9")
</td>
</tr> </tr>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900"> <td class="font-mono">callsign</td>
base_callsign <td>string</td>
</td> <td>Full callsign with SSID (e.g., "N0CALL-9")</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">string</td>
<td class="px-6 py-4 text-sm text-gray-900">Base callsign without SSID</td>
</tr>
<tr class="bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">ssid</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">string</td>
<td class="px-6 py-4 text-sm text-gray-900">
SSID (Secondary Station Identifier), null if not present
</td>
</tr> </tr>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900"> <td class="font-mono">base_callsign</td>
received_at <td>string</td>
</td> <td>Base callsign without SSID</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">datetime</td>
<td class="px-6 py-4 text-sm text-gray-900">
When the packet was received (ISO 8601 format)
</td>
</tr>
<tr class="bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
position
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
<td class="px-6 py-4 text-sm text-gray-900">
Position data (latitude, longitude, course, speed, altitude)
</td>
</tr> </tr>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900"> <td class="font-mono">ssid</td>
symbol <td>string</td>
</td> <td>SSID (Secondary Station Identifier), null if not present</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
<td class="px-6 py-4 text-sm text-gray-900">
APRS symbol information (code and table_id)
</td>
</tr>
<tr class="bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
weather
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
<td class="px-6 py-4 text-sm text-gray-900">
Weather data if present (temperature, humidity, wind, etc.)
</td>
</tr> </tr>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900"> <td class="font-mono">received_at</td>
equipment <td>datetime</td>
</td> <td>When the packet was received (ISO 8601 format)</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
<td class="px-6 py-4 text-sm text-gray-900">
Equipment information (manufacturer, type)
</td>
</tr>
<tr class="bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
message
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">object</td>
<td class="px-6 py-4 text-sm text-gray-900">
Message data if the packet is a message
</td>
</tr> </tr>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900"> <td class="font-mono">position</td>
raw_packet <td>object</td>
</td> <td>Position data (latitude, longitude, course, speed, altitude)</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">string</td> </tr>
<td class="px-6 py-4 text-sm text-gray-900"> <tr>
Original raw APRS packet as received <td class="font-mono">symbol</td>
</td> <td>object</td>
<td>APRS symbol information (code and table_id)</td>
</tr>
<tr>
<td class="font-mono">weather</td>
<td>object</td>
<td>Weather data if present (temperature, humidity, wind, etc.)</td>
</tr>
<tr>
<td class="font-mono">equipment</td>
<td>object</td>
<td>Equipment information (manufacturer, type)</td>
</tr>
<tr>
<td class="font-mono">message</td>
<td>object</td>
<td>Message data if the packet is a message</td>
</tr>
<tr>
<td class="font-mono">raw_packet</td>
<td>string</td>
<td>Original raw APRS packet as received</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -525,62 +470,37 @@ defmodule AprsmeWeb.ApiDocsLive do
</div> </div>
<!-- HTTP Status Codes --> <!-- HTTP Status Codes -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <div class="card bg-base-100 shadow-xl">
<div class="px-6 py-4 border-b border-gray-200"> <div class="card-body">
<h2 class="text-xl font-semibold text-gray-900">HTTP Status Codes</h2> <h2 class="card-title text-xl">HTTP Status Codes</h2>
</div>
<div class="px-6 py-4">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="table table-zebra">
<thead class="bg-gray-50"> <thead>
<tr> <tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th>Status Code</th>
Status Code <th>Description</th>
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-green-600"> <td class="font-mono text-success">200 OK</td>
200 OK <td>Request successful, packet data returned</td>
</td>
<td class="px-6 py-4 text-sm text-gray-900">
Request successful, packet data returned
</td>
</tr>
<tr class="bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-yellow-600">
400 Bad Request
</td>
<td class="px-6 py-4 text-sm text-gray-900">
Invalid callsign format or malformed request
</td>
</tr> </tr>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-red-600"> <td class="font-mono text-warning">400 Bad Request</td>
404 Not Found <td>Invalid callsign format or malformed request</td>
</td>
<td class="px-6 py-4 text-sm text-gray-900">
No packets found for the specified callsign
</td>
</tr>
<tr class="bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-red-600">
408 Request Timeout
</td>
<td class="px-6 py-4 text-sm text-gray-900">Request took too long to process</td>
</tr> </tr>
<tr> <tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-red-600"> <td class="font-mono text-error">404 Not Found</td>
500 Internal Server Error <td>No packets found for the specified callsign</td>
</td> </tr>
<td class="px-6 py-4 text-sm text-gray-900"> <tr>
Server error occurred while processing the request <td class="font-mono text-error">408 Request Timeout</td>
</td> <td>Request took too long to process</td>
</tr>
<tr>
<td class="font-mono text-error">500 Internal Server Error</td>
<td>Server error occurred while processing the request</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@ -589,68 +509,62 @@ defmodule AprsmeWeb.ApiDocsLive do
</div> </div>
<!-- Future Endpoints --> <!-- Future Endpoints -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <div class="card bg-base-100 shadow-xl">
<div class="px-6 py-4 border-b border-gray-200"> <div class="card-body">
<h2 class="text-xl font-semibold text-gray-900">Planned Endpoints</h2> <h2 class="card-title text-xl">Planned Endpoints</h2>
</div> <p class="mb-4">
<div class="px-6 py-4">
<p class="text-gray-700 mb-4">
The following endpoints are planned for future releases: The following endpoints are planned for future releases:
</p> </p>
<div class="space-y-3"> <div class="space-y-3">
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-primary/10 rounded-lg">
<div> <div>
<code class="text-sm font-mono">GET /api/v1/callsign/{"{callsign}"}/history</code> <code class="text-sm font-mono">GET /api/v1/callsign/{"{callsign}"}/history</code>
<p class="text-sm text-gray-600 mt-1">Get historical packets for a callsign</p> <p class="text-sm opacity-70 mt-1">Get historical packets for a callsign</p>
</div> </div>
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">Planned</span> <span class="badge badge-primary">Planned</span>
</div> </div>
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-primary/10 rounded-lg">
<div> <div>
<code class="text-sm font-mono">GET /api/v1/packets/recent</code> <code class="text-sm font-mono">GET /api/v1/packets/recent</code>
<p class="text-sm text-gray-600 mt-1">Get recent packets with filtering options</p> <p class="text-sm opacity-70 mt-1">Get recent packets with filtering options</p>
</div> </div>
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">Planned</span> <span class="badge badge-primary">Planned</span>
</div> </div>
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-primary/10 rounded-lg">
<div> <div>
<code class="text-sm font-mono">GET /api/v1/packets/area</code> <code class="text-sm font-mono">GET /api/v1/packets/area</code>
<p class="text-sm text-gray-600 mt-1">Get packets within a geographic area</p> <p class="text-sm opacity-70 mt-1">Get packets within a geographic area</p>
</div> </div>
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">Planned</span> <span class="badge badge-primary">Planned</span>
</div> </div>
<div class="flex items-center justify-between p-3 bg-blue-50 rounded-lg"> <div class="flex items-center justify-between p-3 bg-primary/10 rounded-lg">
<div> <div>
<code class="text-sm font-mono">GET /api/v1/weather/{"{callsign}"}</code> <code class="text-sm font-mono">GET /api/v1/weather/{"{callsign}"}</code>
<p class="text-sm text-gray-600 mt-1">Get weather data from weather stations</p> <p class="text-sm opacity-70 mt-1">Get weather data from weather stations</p>
</div> </div>
<span class="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">Planned</span> <span class="badge badge-primary">Planned</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Interactive API Testing --> <!-- Interactive API Testing -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <div class="card bg-base-100 shadow-xl">
<div class="px-6 py-4 border-b border-gray-200"> <div class="card-body">
<h2 class="text-xl font-semibold text-gray-900">Test the API</h2> <h2 class="card-title text-xl">Test the API</h2>
</div> <p class="mb-4">
<div class="px-6 py-4">
<p class="text-gray-700 mb-4">
Try the API directly from this page. Enter a callsign to see the most recent packet data. Try the API directly from this page. Enter a callsign to see the most recent packet data.
</p> </p>
<div class="max-w-md"> <div class="max-w-md">
<form phx-submit="test_api" class="space-y-4"> <form phx-submit="test_api" class="space-y-4">
<div> <div>
<label for="test_callsign" class="block text-sm font-medium text-gray-700 mb-2"> <label for="test_callsign" class="label">
Callsign (e.g., N0CALL or N0CALL-9) <span class="label-text">Callsign (e.g., N0CALL or N0CALL-9)</span>
</label> </label>
<div class="flex space-x-2"> <div class="flex space-x-2">
<input <input
@ -660,38 +574,16 @@ defmodule AprsmeWeb.ApiDocsLive do
value={@test_callsign} value={@test_callsign}
phx-change="update_callsign" phx-change="update_callsign"
placeholder="Enter callsign..." placeholder="Enter callsign..."
class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" class="input input-bordered flex-1"
disabled={@loading} disabled={@loading}
/> />
<button <button
type="submit" type="submit"
disabled={@loading or String.trim(@test_callsign) == ""} disabled={@loading or String.trim(@test_callsign) == ""}
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" class="btn btn-primary"
> >
<%= if @loading do %> <%= if @loading do %>
<svg <span class="loading loading-spinner loading-sm"></span> Testing...
class="animate-spin -ml-1 mr-2 h-4 w-4 text-white inline"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
>
</circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
>
</path>
</svg>
Testing...
<% else %> <% else %>
Test API Test API
<% end %> <% end %>
@ -702,42 +594,42 @@ defmodule AprsmeWeb.ApiDocsLive do
<!-- Error Display --> <!-- Error Display -->
<%= if @error do %> <%= if @error do %>
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-md"> <div class="alert alert-error mt-4">
<div class="flex"> <svg
<div class="flex-shrink-0"> xmlns="http://www.w3.org/2000/svg"
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> class="stroke-current shrink-0 h-6 w-6"
<path fill="none"
fill-rule="evenodd" viewBox="0 0 24 24"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" >
clip-rule="evenodd" <path
/> stroke-linecap="round"
</svg> stroke-linejoin="round"
</div> stroke-width="2"
<div class="ml-3"> d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
<p class="text-sm text-red-800">{@error}</p> />
</div> </svg>
</div> <span>{@error}</span>
</div> </div>
<% end %> <% end %>
<!-- Results Display --> <!-- Results Display -->
<%= if @api_result do %> <%= if @api_result do %>
<div class="mt-4"> <div class="mt-4">
<h4 class="text-lg font-medium text-gray-900 mb-2">API Response</h4> <h4 class="text-lg font-medium mb-2">API Response</h4>
<div class="bg-gray-50 border border-gray-200 rounded-lg p-4"> <div class="bg-base-200 border rounded-lg p-4">
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<svg class="h-5 w-5 text-blue-500 mr-2" viewBox="0 0 20 20" fill="currentColor"> <svg class="h-5 w-5 text-primary mr-2" viewBox="0 0 20 20" fill="currentColor">
<path <path
fill-rule="evenodd" fill-rule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z" d="M3 4a1 1 0 011-1h12a1 1 0 011 1v2a1 1 0 01-1 1H4a1 1 0 01-1-1V4zM3 10a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H4a1 1 0 01-1-1v-6zM14 9a1 1 0 00-1 1v6a1 1 0 001 1h2a1 1 0 001-1v-6a1 1 0 00-1-1h-2z"
clip-rule="evenodd" clip-rule="evenodd"
/> />
</svg> </svg>
<span class="text-gray-800 font-medium">JSON Response</span> <span class="font-medium">JSON Response</span>
</div> </div>
<div class="bg-gray-900 text-white p-4 rounded text-sm overflow-x-auto"> <div class="bg-neutral text-neutral-content p-4 rounded text-sm overflow-x-auto">
<pre><%= @api_result %></pre> <pre><%= @api_result %></pre>
</div> </div>
</div> </div>
@ -748,27 +640,22 @@ defmodule AprsmeWeb.ApiDocsLive do
</div> </div>
<!-- Contact and Support --> <!-- Contact and Support -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200"> <div class="card bg-base-100 shadow-xl">
<div class="px-6 py-4 border-b border-gray-200"> <div class="card-body">
<h2 class="text-xl font-semibold text-gray-900">Support</h2> <h2 class="card-title text-xl">Support</h2>
</div> <p class="mb-4">
This API is provided free of charge for amateur radio and educational purposes.
If you encounter issues or have suggestions for improvements, please reach out.
</p>
<div class="px-6 py-4"> <div class="bg-base-200 p-4 rounded-lg">
<div class="prose max-w-none"> <h3 class="font-semibold mb-2">Guidelines</h3>
<p class="text-gray-700 mb-4"> <ul class="space-y-1">
This API is provided free of charge for amateur radio and educational purposes. <li> Use reasonable request rates to avoid overwhelming the service</li>
If you encounter issues or have suggestions for improvements, please reach out. <li> Cache responses when appropriate to reduce server load</li>
</p> <li> Include a User-Agent header identifying your application</li>
<li> This service is for amateur radio and educational use</li>
<div class="bg-gray-50 p-4 rounded-lg"> </ul>
<h3 class="font-semibold text-gray-900 mb-2">Guidelines</h3>
<ul class="text-gray-700 space-y-1">
<li> Use reasonable request rates to avoid overwhelming the service</li>
<li> Cache responses when appropriate to reduce server load</li>
<li> Include a User-Agent header identifying your application</li>
<li> This service is for amateur radio and educational use</li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -16,18 +16,18 @@
<% _symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %> <% _symbol_img = "/aprs-symbols/aprs-symbols-24-#{symbol_table_num}@2x.png" %>
<% _symbol_index = symbol_code_ord - 33 %> <% _symbol_index = symbol_code_ord - 33 %>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-base-200">
<!-- Page header --> <!-- Page header -->
<div class="bg-white shadow-sm border-b"> <div class="bg-base-100 shadow-sm">
<div class="px-4 sm:px-6 lg:px-8"> <div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-4 md:flex md:items-center md:justify-between"> <div class="py-4 md:flex md:items-center md:justify-between">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex items-center"> <div class="flex items-center">
<h1 class="text-xl font-bold leading-7 text-gray-900 sm:truncate sm:text-2xl"> <h1 class="text-xl font-bold leading-7 sm:truncate sm:text-2xl">
APRS Station APRS Station
</h1> </h1>
<div class="ml-3 flex items-center space-x-3"> <div class="ml-3 flex items-center space-x-3">
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-sm font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10"> <span class="badge badge-primary">
{@callsign} {@callsign}
</span> </span>
<%= if @packet do %> <%= if @packet do %>
@ -63,17 +63,11 @@
</div> </div>
<div class="mt-3 flex md:ml-4 md:mt-0"> <div class="mt-3 flex md:ml-4 md:mt-0">
<div class="flex space-x-2"> <div class="flex space-x-2">
<.link <.link navigate={~p"/packets/#{@callsign}"} class="btn btn-outline btn-sm">
navigate={~p"/packets/#{@callsign}"}
class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
View packets View packets
</.link> </.link>
<%= if AprsmeWeb.MapLive.PacketUtils.has_weather_packets?(@callsign) do %> <%= if AprsmeWeb.MapLive.PacketUtils.has_weather_packets?(@callsign) do %>
<.link <.link navigate={~p"/weather/#{@callsign}"} class="btn btn-primary btn-sm">
navigate={~p"/weather/#{@callsign}"}
class="inline-flex items-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
Weather charts Weather charts
</.link> </.link>
<% end %> <% end %>
@ -83,17 +77,17 @@
</div> </div>
</div> </div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4"> <div class="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4">
<%= if @packet do %> <%= if @packet do %>
<!-- Station details --> <!-- Station details -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mb-6"> <div class="grid grid-cols-1 gap-4 lg:grid-cols-2 mb-6">
<!-- Position information --> <!-- Position information -->
<div class="bg-white overflow-hidden shadow-sm rounded-lg border"> <div class="card bg-base-100 shadow-xl">
<div class="px-4 py-3"> <div class="card-body">
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg <svg
class="h-4 w-4 text-gray-400" class="h-4 w-4 opacity-70"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
@ -112,38 +106,38 @@
</svg> </svg>
</div> </div>
<div class="ml-2"> <div class="ml-2">
<h3 class="text-sm font-medium text-gray-900">Position Information</h3> <h3 class="text-sm font-medium">Position Information</h3>
</div> </div>
</div> </div>
<dl class="grid grid-cols-2 gap-3"> <dl class="grid grid-cols-2 gap-3">
<div> <div>
<dt class="text-xs font-medium text-gray-500">Location</dt> <dt class="text-xs font-medium opacity-70">Location</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{@packet.lat || ""}, {@packet.lon || ""} {@packet.lat || ""}, {@packet.lon || ""}
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Last Position</dt> <dt class="text-xs font-medium opacity-70">Last Position</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{AprsmeWeb.MapLive.PacketUtils.get_timestamp(@packet)} {AprsmeWeb.MapLive.PacketUtils.get_timestamp(@packet)}
</dd> </dd>
</div> </div>
<%= if @packet.altitude do %> <%= if @packet.altitude do %>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Altitude</dt> <dt class="text-xs font-medium opacity-70">Altitude</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{@packet.altitude} ft</dd> <dd class="mt-1 text-sm font-semibold">{@packet.altitude} ft</dd>
</div> </div>
<% end %> <% end %>
<%= if @packet.course do %> <%= if @packet.course do %>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Course</dt> <dt class="text-xs font-medium opacity-70">Course</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{@packet.course}°</dd> <dd class="mt-1 text-sm font-semibold">{@packet.course}°</dd>
</div> </div>
<% end %> <% end %>
<%= if @packet.speed do %> <%= if @packet.speed do %>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Speed</dt> <dt class="text-xs font-medium opacity-70">Speed</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{@packet.speed} MPH</dd> <dd class="mt-1 text-sm font-semibold">{@packet.speed} MPH</dd>
</div> </div>
<% end %> <% end %>
</dl> </dl>
@ -151,12 +145,12 @@
</div> </div>
<!-- Device information --> <!-- Device information -->
<div class="bg-white overflow-hidden shadow-sm rounded-lg border"> <div class="card bg-base-100 shadow-xl">
<div class="px-4 py-3"> <div class="card-body">
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg <svg
class="h-4 w-4 text-gray-400" class="h-4 w-4 opacity-70"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
@ -170,13 +164,13 @@
</svg> </svg>
</div> </div>
<div class="ml-2"> <div class="ml-2">
<h3 class="text-sm font-medium text-gray-900">Device Information</h3> <h3 class="text-sm font-medium">Device Information</h3>
</div> </div>
</div> </div>
<dl class="grid grid-cols-2 gap-3"> <dl class="grid grid-cols-2 gap-3">
<div> <div>
<dt class="text-xs font-medium text-gray-500">Device</dt> <dt class="text-xs font-medium opacity-70">Device</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
<%= if @packet.device_model || @packet.device_vendor do %> <%= if @packet.device_model || @packet.device_vendor do %>
{[ {[
@packet.device_model, @packet.device_model,
@ -189,19 +183,19 @@
({@packet.device_class}) ({@packet.device_class})
<% end %> <% end %>
<% else %> <% else %>
<span class="text-gray-500 font-mono"> <span class="opacity-70 font-mono">
{@packet.device_identifier || "Unknown"} {@packet.device_identifier || "Unknown"}
</span> </span>
<% end %> <% end %>
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Path</dt> <dt class="text-xs font-medium opacity-70">Path</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900">{@packet.path || ""}</dd> <dd class="mt-1 text-sm font-semibold">{@packet.path || ""}</dd>
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<dt class="text-xs font-medium text-gray-500">Raw Packet</dt> <dt class="text-xs font-medium opacity-70">Raw Packet</dt>
<dd class="mt-1 text-xs font-mono text-gray-900 break-all"> <dd class="mt-1 text-xs font-mono break-all">
<%= if is_binary(@packet.raw_packet) do %> <%= if is_binary(@packet.raw_packet) do %>
{Aprsme.EncodingUtils.sanitize_string(@packet.raw_packet)} {Aprsme.EncodingUtils.sanitize_string(@packet.raw_packet)}
<% else %> <% else %>
@ -215,12 +209,12 @@
</div> </div>
<!-- Neighboring stations --> <!-- Neighboring stations -->
<div class="bg-white shadow-sm rounded-lg border"> <div class="card bg-base-100 shadow-xl">
<div class="px-4 py-3"> <div class="card-body">
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg <svg
class="h-4 w-4 text-gray-400" class="h-4 w-4 opacity-70"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
@ -234,42 +228,30 @@
</svg> </svg>
</div> </div>
<div class="ml-2"> <div class="ml-2">
<h3 class="text-sm font-medium text-gray-900">Stations Near Current Position</h3> <h3 class="text-sm font-medium">Stations Near Current Position</h3>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="table table-zebra">
<thead class="bg-gray-50"> <thead>
<tr> <tr>
<th <th class="text-xs font-medium uppercase tracking-wider">
scope="col"
class="py-2 pl-3 pr-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Callsign Callsign
</th> </th>
<th <th class="text-xs font-medium uppercase tracking-wider">
scope="col"
class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Distance & Course Distance & Course
</th> </th>
<th <th class="text-xs font-medium uppercase tracking-wider">
scope="col"
class="px-2 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Last Heard Last Heard
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody>
<%= for neighbor <- @neighbors do %> <%= for neighbor <- @neighbors do %>
<tr> <tr>
<td class="whitespace-nowrap py-2 pl-3 pr-2 text-sm font-medium text-gray-900"> <td class="font-medium">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<.link <.link navigate={~p"/info/#{neighbor.callsign}"} class="link link-primary">
navigate={~p"/info/#{neighbor.callsign}"}
class="text-blue-600 hover:text-blue-900"
>
{neighbor.callsign} {neighbor.callsign}
</.link> </.link>
<%= if neighbor.packet do %> <%= if neighbor.packet do %>
@ -302,14 +284,14 @@
<% end %> <% end %>
</div> </div>
</td> </td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500"> <td class="opacity-70">
<%= if neighbor.course do %> <%= if neighbor.course do %>
{neighbor.distance || ""} @ {Float.round(neighbor.course, 0)}° {neighbor.distance || ""} @ {Float.round(neighbor.course, 0)}°
<% else %> <% else %>
{neighbor.distance || ""} {neighbor.distance || ""}
<% end %> <% end %>
</td> </td>
<td class="whitespace-nowrap px-2 py-2 text-sm text-gray-500"> <td class="opacity-70">
{neighbor.last_heard || ""} {neighbor.last_heard || ""}
</td> </td>
</tr> </tr>
@ -322,7 +304,7 @@
<% else %> <% else %>
<div class="text-center py-8"> <div class="text-center py-8">
<svg <svg
class="mx-auto h-8 w-8 text-gray-400" class="mx-auto h-8 w-8 opacity-70"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke-width="1.5" stroke-width="1.5"
@ -335,8 +317,8 @@
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423L16.5 15.75l.394 1.183a2.25 2.25 0 001.423 1.423L19.5 18.75l-1.183.394a2.25 2.25 0 00-1.423 1.423z" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423L16.5 15.75l.394 1.183a2.25 2.25 0 001.423 1.423L19.5 18.75l-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/> />
</svg> </svg>
<h3 class="mt-2 text-sm font-semibold text-gray-900">No data available</h3> <h3 class="mt-2 text-sm font-semibold">No data available</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm opacity-70">
No recent packet data available for this callsign. No recent packet data available for this callsign.
</p> </p>
</div> </div>

View file

@ -1,86 +1,106 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8">
<%= if @error do %> <%= if @error do %>
<div class="mt-6 bg-red-50 border border-red-200 rounded-md p-4"> <div class="alert alert-error">
<div class="flex"> <svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<div class="flex-shrink-0"> <path
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor"> fill-rule="evenodd"
<path d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
fill-rule="evenodd" clip-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" />
clip-rule="evenodd" </svg>
/> <div>
</svg> <h3 class="font-medium">Error</h3>
</div> <div class="text-sm">
<div class="ml-3"> {@error}
<h3 class="text-sm font-medium text-red-800">Error</h3>
<div class="mt-1 text-sm text-red-700">
{@error}
</div>
</div> </div>
</div> </div>
</div> </div>
<% else %> <% else %>
<div class="mt-6 bg-white shadow-sm rounded-lg overflow-hidden"> <div class="card bg-base-100 shadow-xl">
<.table id="callsign-packets" rows={@all_packets}> <div class="card-body">
<:col :let={packet} label="Time"> <div class="overflow-x-auto">
<span class="text-sm text-gray-600 font-mono"> <table class="table table-zebra">
<%= case packet.received_at do %> <thead>
<% %DateTime{} = dt -> %> <tr>
{Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")} <th>Time</th>
<% dt when is_binary(dt) -> %> <th>Sender</th>
<%= case DateTime.from_iso8601(dt) do %> <th>Data Type</th>
<% {:ok, parsed_dt, _} -> %> <th>Information</th>
{Calendar.strftime(parsed_dt, "%Y-%m-%d %H:%M:%S")} <th>Path</th>
<% _ -> %> <th>Device</th>
{dt} </tr>
<% end %> </thead>
<% _ -> %> <tbody>
N/A <%= for packet <- @all_packets do %>
<% end %> <tr>
</span> <td>
</:col> <span class="text-sm font-mono opacity-70">
<:col :let={packet} label="Sender"> <%= case packet.received_at do %>
<span class="text-sm text-gray-900 font-medium">{packet.sender}</span> <% %DateTime{} = dt -> %>
</:col> {Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")}
<:col :let={packet} label="Data Type"> <% dt when is_binary(dt) -> %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> <%= case DateTime.from_iso8601(dt) do %>
{packet.data_type} <% {:ok, parsed_dt, _} -> %>
</span> {Calendar.strftime(parsed_dt, "%Y-%m-%d %H:%M:%S")}
</:col> <% _ -> %>
<:col :let={packet} label="Information"> {dt}
<span class="text-sm text-gray-900 font-mono"> <% end %>
<%= if String.length(packet.information_field || "") > 50 do %> <% _ -> %>
<span title={packet.information_field}> N/A
{String.slice(packet.information_field, 0, 50)}... <% end %>
</span> </span>
<% else %> </td>
{packet.information_field} <td>
<% end %> <span class="text-sm font-medium">{packet.sender}</span>
</span> </td>
</:col> <td>
<:col :let={packet} label="Path"> <span class="badge badge-primary">
<span class="text-xs text-gray-500 font-mono"> {packet.data_type}
{packet.path} </span>
</span> </td>
</:col> <td>
<:col :let={packet} label="Device"> <span class="text-sm font-mono">
<span class="text-xs text-gray-700 font-mono"> <%= if String.length(packet.information_field || "") > 50 do %>
<%= if packet.device_model || packet.device_vendor do %> <span title={packet.information_field}>
{[packet.device_model, packet.device_vendor] {String.slice(packet.information_field, 0, 50)}...
|> Enum.reject(&is_nil/1) </span>
|> Enum.join(" ")} <% else %>
<% else %> {packet.information_field}
{Map.get(packet, :device_identifier, Map.get(packet, "device_identifier", ""))} <% end %>
<% end %> </span>
</span> </td>
</:col> <td>
</.table> <span class="text-xs font-mono opacity-70">
{packet.path}
</span>
</td>
<td>
<span class="text-xs font-mono opacity-70">
<%= if packet.device_model || packet.device_vendor do %>
{[packet.device_model, packet.device_vendor]
|> Enum.reject(&is_nil/1)
|> Enum.join(" ")}
<% else %>
{Map.get(
packet,
:device_identifier,
Map.get(packet, "device_identifier", "")
)}
<% end %>
</span>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div> </div>
<%= if length(@all_packets) == 0 do %> <%= if length(@all_packets) == 0 do %>
<div class="mt-8 text-center"> <div class="text-center py-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100"> <div class="flex justify-center mb-4">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-16 h-16 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@ -89,25 +109,27 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="mt-2 text-sm font-medium text-gray-900">No packets found for {@callsign}</h3> <h3 class="text-lg font-medium mb-2">No packets found for {@callsign}</h3>
<p class="mt-1 text-sm text-gray-500"> <p class="opacity-70">
Packets will appear here as they are received live or if there are stored packets for this callsign. Packets will appear here as they are received live or if there are stored packets for this callsign.
</p> </p>
</div> </div>
<% end %> <% end %>
<div class="mt-4 px-4 py-3 bg-gray-50 border-t border-gray-200 rounded-b-lg"> <div class="card bg-base-100 shadow-xl mt-4">
<div class="flex items-center justify-between text-sm text-gray-500"> <div class="card-body">
<div class="flex items-center space-x-4"> <div class="flex items-center justify-between text-sm">
<span> <div class="flex items-center space-x-4">
Live packets: <span class="font-medium text-gray-900">{length(@live_packets)}</span> <span>
</span> Live packets: <span class="font-medium">{length(@live_packets)}</span>
<span> </span>
Stored packets: <span class="font-medium text-gray-900">{length(@packets)}</span> <span>
</span> Stored packets: <span class="font-medium">{length(@packets)}</span>
<span> </span>
Total: <span class="font-medium text-gray-900">{length(@all_packets)}/100</span> <span>
</span> Total: <span class="font-medium">{length(@all_packets)}/100</span>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,132 +1,158 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 my-8"> <div class="container mx-auto max-w-7xl py-8">
<div class="mt-6 bg-white shadow-sm rounded-lg overflow-hidden"> <div class="card bg-base-100 shadow-xl">
<.table id="packets" rows={@packets}> <div class="card-body">
<:col :let={packet} label="Sender"> <div class="overflow-x-auto">
<.link <table class="table table-zebra">
navigate={ <thead>
~p"/packets/#{Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))}" <tr>
} <th>Sender</th>
class="text-blue-600 hover:text-blue-800 font-medium" <th>SSID</th>
> <th>Base Callsign</th>
{Map.get(packet, :sender, Map.get(packet, "sender", ""))} <th>Data Type</th>
</.link> <th>Symbol</th>
</:col> <th>Path</th>
<:col :let={packet} label="SSID"> <th>Latitude</th>
<span class="text-sm text-gray-900"> <th>Longitude</th>
{Map.get(packet, :ssid, Map.get(packet, "ssid", ""))} <th>Device</th>
</span> </tr>
</:col> </thead>
<:col :let={packet} label="Base Callsign"> <tbody>
<.link <%= for packet <- @packets do %>
navigate={ <tr>
~p"/packets/#{Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))}" <td>
} <.link
class="text-blue-600 hover:text-blue-800 font-medium" navigate={
> ~p"/packets/#{Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))}"
{Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))} }
</.link> class="link link-primary font-medium"
</:col> >
<:col :let={packet} label="Data Type"> {Map.get(packet, :sender, Map.get(packet, "sender", ""))}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"> </.link>
{Map.get(packet, :data_type, Map.get(packet, "data_type", ""))} </td>
</span> <td>
</:col> <span class="text-sm">
<:col :let={packet} label="Symbol"> {Map.get(packet, :ssid, Map.get(packet, "ssid", ""))}
<span class="text-xs text-gray-700 font-mono"> </span>
<% data = Map.get(packet, :data_extended) || %{} </td>
raw_table = Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/" <td>
table = if raw_table in ["/", "\\", "]"], do: raw_table, else: "/" <.link
code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" %> navigate={
{table}{code} ~p"/packets/#{Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))}"
</span> }
</:col> class="link link-primary font-medium"
<:col :let={packet} label="Path"> >
<span class="text-xs text-gray-500 font-mono"> {Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))}
{Map.get(packet, :path, Map.get(packet, "path", ""))} </.link>
</span> </td>
</:col> <td>
<:col :let={packet} label="Latitude"> <span class="badge badge-primary">
<span class="text-xs text-gray-700 font-mono"> {Map.get(packet, :data_type, Map.get(packet, "data_type", ""))}
<% lat = </span>
Map.get(packet, :lat) || </td>
if( <td>
Map.has_key?(packet, :location) and not is_nil(packet.location) and <span class="text-xs font-mono">
function_exported?(Aprsme.Packet, :lat, 1), <% data = Map.get(packet, :data_extended) || %{}
do: Aprsme.Packet.lat(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
%{} = data_ext ->
case Map.get(data_ext, :latitude) || Map.get(data_ext, "latitude") do
{:ok, value} -> value
value -> value
end
_ -> raw_table =
nil Map.get(data, :symbol_table_id) || Map.get(data, "symbol_table_id") || "/"
end %>
<%= if not is_nil(lat) do %>
<%= if is_float(lat) do %>
{:io_lib.format("~.6f", [lat]) |> List.to_string()}
<% else %>
<%= if is_binary(lat) do %>
{Regex.replace(~r/(\d+\.\d{1,6})\d*/, lat, "\\1")}
<% else %>
{lat}
<% end %>
<% end %>
<% else %>
""
<% end %>
</span>
</:col>
<:col :let={packet} label="Longitude">
<span class="text-xs text-gray-700 font-mono">
<% lon =
Map.get(packet, :lon) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprsme.Packet, :lon, 1),
do: Aprsme.Packet.lon(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
%{} = data_ext ->
case Map.get(data_ext, :longitude) || Map.get(data_ext, "longitude") do
{:ok, value} -> value
value -> value
end
_ -> table = if raw_table in ["/", "\\", "]"], do: raw_table, else: "/"
nil code = Map.get(data, :symbol_code) || Map.get(data, "symbol_code") || ">" %>
end %> {table}{code}
<%= if not is_nil(lon) do %> </span>
<%= if is_float(lon) do %> </td>
{:io_lib.format("~.6f", [lon]) |> List.to_string()} <td>
<% else %> <span class="text-xs font-mono opacity-70">
<%= if is_binary(lon) do %> {Map.get(packet, :path, Map.get(packet, "path", ""))}
{Regex.replace(~r/(\d+\.\d{1,6})\d*/, lon, "\\1")} </span>
<% else %> </td>
{lon} <td>
<% end %> <span class="text-xs font-mono">
<% lat =
Map.get(packet, :lat) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprsme.Packet, :lat, 1),
do: Aprsme.Packet.lat(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
%{} = data_ext ->
case Map.get(data_ext, :latitude) || Map.get(data_ext, "latitude") do
{:ok, value} -> value
value -> value
end
_ ->
nil
end %>
<%= if not is_nil(lat) do %>
<%= if is_float(lat) do %>
{:io_lib.format("~.6f", [lat]) |> List.to_string()}
<% else %>
<%= if is_binary(lat) do %>
{Regex.replace(~r/(\d+\.\d{1,6})\d*/, lat, "\\1")}
<% else %>
{lat}
<% end %>
<% end %>
<% else %>
""
<% end %>
</span>
</td>
<td>
<span class="text-xs font-mono">
<% lon =
Map.get(packet, :lon) ||
if(
Map.has_key?(packet, :location) and not is_nil(packet.location) and
function_exported?(Aprsme.Packet, :lon, 1),
do: Aprsme.Packet.lon(packet),
else: nil
) ||
case Map.get(packet, :data_extended) do
%{} = data_ext ->
case Map.get(data_ext, :longitude) || Map.get(data_ext, "longitude") do
{:ok, value} -> value
value -> value
end
_ ->
nil
end %>
<%= if not is_nil(lon) do %>
<%= if is_float(lon) do %>
{:io_lib.format("~.6f", [lon]) |> List.to_string()}
<% else %>
<%= if is_binary(lon) do %>
{Regex.replace(~r/(\d+\.\d{1,6})\d*/, lon, "\\1")}
<% else %>
{lon}
<% end %>
<% end %>
<% else %>
""
<% end %>
</span>
</td>
<td>
<span class="text-xs font-mono">
{Map.get(packet, :device_identifier, Map.get(packet, "device_identifier", ""))}
</span>
</td>
</tr>
<% end %> <% end %>
<% else %> </tbody>
"" </table>
<% end %> </div>
</span> </div>
</:col>
<:col :let={packet} label="Device">
<span class="text-xs text-gray-700 font-mono">
{Map.get(packet, :device_identifier, Map.get(packet, "device_identifier", ""))}
</span>
</:col>
</.table>
</div> </div>
<%= if length(@packets) == 0 do %> <%= if length(@packets) == 0 do %>
<div class="mt-8 text-center"> <div class="text-center py-12">
<div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-gray-100"> <div class="flex justify-center mb-4">
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-16 h-16 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@ -135,8 +161,8 @@
/> />
</svg> </svg>
</div> </div>
<h3 class="mt-2 text-sm font-medium text-gray-900">No packets</h3> <h3 class="text-lg font-medium mb-2">No packets</h3>
<p class="mt-1 text-sm text-gray-500">Packets will appear here as they are received.</p> <p class="opacity-70">Packets will appear here as they are received.</p>
</div> </div>
<% end %> <% end %>
</div> </div>

View file

@ -1,14 +1,14 @@
<%= if @weather_packet do %> <%= if @weather_packet do %>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-base-200">
<div class="bg-white shadow-sm border-b"> <div class="bg-base-100 shadow-sm">
<div class="px-4 sm:px-6 lg:px-8"> <div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-4 md:flex md:items-center md:justify-between"> <div class="py-4 md:flex md:items-center md:justify-between">
<div class="flex items-center"> <div class="flex items-center">
<h1 class="text-xl font-bold leading-7 text-gray-900 sm:truncate sm:text-2xl"> <h1 class="text-xl font-bold leading-7 sm:truncate sm:text-2xl">
Weather Station Weather Station
</h1> </h1>
<div class="ml-3 flex items-center space-x-3"> <div class="ml-3 flex items-center space-x-3">
<span class="inline-flex items-center rounded-md bg-blue-50 px-2 py-1 text-sm font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10"> <span class="badge badge-primary">
{@callsign} {@callsign}
</span> </span>
<% {symbol_table_id, symbol_code} = <% {symbol_table_id, symbol_code} =
@ -39,22 +39,13 @@
</div> </div>
<div class="mt-3 flex md:ml-4 md:mt-0"> <div class="mt-3 flex md:ml-4 md:mt-0">
<div class="flex space-x-2"> <div class="flex space-x-2">
<.link <.link navigate={~p"/info/#{@callsign}"} class="btn btn-outline btn-sm">
navigate={~p"/info/#{@callsign}"}
class="inline-flex items-center rounded-md bg-gray-100 px-3 py-1.5 text-sm font-semibold text-gray-700 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-200"
>
View info View info
</.link> </.link>
<.link <.link navigate={~p"/packets/#{@callsign}"} class="btn btn-outline btn-sm">
navigate={~p"/packets/#{@callsign}"}
class="inline-flex items-center rounded-md bg-white px-3 py-1.5 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
>
View packets View packets
</.link> </.link>
<.link <.link navigate={~p"/#{@callsign}"} class="btn btn-primary btn-sm">
navigate={~p"/#{@callsign}"}
class="inline-flex items-center rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
>
View on map View on map
</.link> </.link>
</div> </div>
@ -62,9 +53,9 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4"> <div class="container mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4">
<div class="bg-white overflow-hidden shadow-sm rounded-lg border mb-6"> <div class="card bg-base-100 shadow-xl mb-6">
<div class="px-4 py-3"> <div class="card-body">
<% dt = <% dt =
AprsmeWeb.TimeHelpers.to_datetime( AprsmeWeb.TimeHelpers.to_datetime(
Map.get(@weather_packet, :inserted_at) || Map.get(@weather_packet, "inserted_at") Map.get(@weather_packet, :inserted_at) || Map.get(@weather_packet, "inserted_at")
@ -72,7 +63,7 @@
<div class="flex items-center mb-3"> <div class="flex items-center mb-3">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<svg <svg
class="h-4 w-4 text-blue-400" class="h-4 w-4 text-primary"
viewBox="0 0 20 20" viewBox="0 0 20 20"
fill="currentColor" fill="currentColor"
aria-hidden="true" aria-hidden="true"
@ -85,13 +76,13 @@
</svg> </svg>
</div> </div>
<div class="ml-2"> <div class="ml-2">
<h3 class="text-sm font-medium text-gray-900">Weather Details</h3> <h3 class="text-sm font-medium">Weather Details</h3>
</div> </div>
</div> </div>
<dl class="grid grid-cols-2 gap-3"> <dl class="grid grid-cols-2 gap-3">
<div> <div>
<dt class="text-xs font-medium text-gray-500">Last WX report</dt> <dt class="text-xs font-medium opacity-70">Last WX report</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
<%= if dt do %> <%= if dt do %>
{Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC")} ({AprsmeWeb.TimeHelpers.time_ago_in_words( {Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S UTC")} ({AprsmeWeb.TimeHelpers.time_ago_in_words(
dt dt
@ -103,20 +94,20 @@
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Temperature</dt> <dt class="text-xs font-medium opacity-70">Temperature</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{PacketUtils.get_weather_field(@weather_packet, :temperature)}°F {PacketUtils.get_weather_field(@weather_packet, :temperature)}°F
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Humidity</dt> <dt class="text-xs font-medium opacity-70">Humidity</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{PacketUtils.get_weather_field(@weather_packet, :humidity)}% {PacketUtils.get_weather_field(@weather_packet, :humidity)}%
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Wind</dt> <dt class="text-xs font-medium opacity-70">Wind</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{PacketUtils.get_weather_field(@weather_packet, :wind_direction)}° @ {PacketUtils.get_weather_field( {PacketUtils.get_weather_field(@weather_packet, :wind_direction)}° @ {PacketUtils.get_weather_field(
@weather_packet, @weather_packet,
:wind_speed :wind_speed
@ -124,38 +115,38 @@
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Gusts</dt> <dt class="text-xs font-medium opacity-70">Gusts</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{PacketUtils.get_weather_field(@weather_packet, :wind_gust)} mph {PacketUtils.get_weather_field(@weather_packet, :wind_gust)} mph
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Pressure</dt> <dt class="text-xs font-medium opacity-70">Pressure</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{PacketUtils.get_weather_field(@weather_packet, :pressure)} hPa {PacketUtils.get_weather_field(@weather_packet, :pressure)} hPa
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Rain (1h)</dt> <dt class="text-xs font-medium opacity-70">Rain (1h)</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{get_weather_field_zero(@weather_packet, :rain_1h)} in {get_weather_field_zero(@weather_packet, :rain_1h)} in
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Rain (24h)</dt> <dt class="text-xs font-medium opacity-70">Rain (24h)</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{get_weather_field_zero(@weather_packet, :rain_24h)} in {get_weather_field_zero(@weather_packet, :rain_24h)} in
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-xs font-medium text-gray-500">Rain (since midnight)</dt> <dt class="text-xs font-medium opacity-70">Rain (since midnight)</dt>
<dd class="mt-1 text-sm font-semibold text-gray-900"> <dd class="mt-1 text-sm font-semibold">
{get_weather_field_zero(@weather_packet, :rain_since_midnight)} in {get_weather_field_zero(@weather_packet, :rain_since_midnight)} in
</dd> </dd>
</div> </div>
<div class="col-span-2"> <div class="col-span-2">
<dt class="text-xs font-medium text-gray-500">Raw Packet</dt> <dt class="text-xs font-medium opacity-70">Raw Packet</dt>
<dd class="mt-1 text-xs font-mono text-gray-900 break-all"> <dd class="mt-1 text-xs font-mono break-all">
<%= if is_binary(@weather_packet.raw_packet) do %> <%= if is_binary(@weather_packet.raw_packet) do %>
{Aprsme.EncodingUtils.sanitize_string(@weather_packet.raw_packet)} {Aprsme.EncodingUtils.sanitize_string(@weather_packet.raw_packet)}
<% else %> <% else %>
@ -164,19 +155,20 @@
</dd> </dd>
</div> </div>
</dl> </dl>
<div class="mt-2 text-xs text-gray-500"> <div class="mt-2 text-xs opacity-70">
<strong>Raw comment:</strong> {@weather_packet.comment || @weather_packet["comment"]} <strong>Raw comment:</strong> {@weather_packet.comment || @weather_packet["comment"]}
</div> </div>
</div> </div>
</div> </div>
<div class="bg-white overflow-hidden shadow-sm rounded-lg border"> <div class="card bg-base-100 shadow-xl">
<div class="px-4 py-3"> <div class="card-body">
<h3 class="text-sm font-medium text-gray-900 mb-2">Weather History Graphs</h3> <h3 class="text-sm font-medium mb-2">Weather History Graphs</h3>
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :temperature, nil)))) do %> <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :temperature, nil)))) do %>
<div <div
id="temp-chart" id="temp-chart"
phx-hook="ChartJSTempChart" phx-hook="ChartJSTempChart"
data-weather-history={@weather_history_json} data-weather-history={@weather_history_json}
class="bg-base-200 rounded-lg p-4 mb-4"
style="height:250px;" style="height:250px;"
> >
<canvas></canvas> <canvas></canvas>
@ -187,6 +179,7 @@
id="humidity-chart" id="humidity-chart"
phx-hook="ChartJSHumidityChart" phx-hook="ChartJSHumidityChart"
data-weather-history={@weather_history_json} data-weather-history={@weather_history_json}
class="bg-base-200 rounded-lg p-4 mb-4"
style="height:250px;" style="height:250px;"
> >
<canvas></canvas> <canvas></canvas>
@ -197,16 +190,7 @@
id="pressure-chart" id="pressure-chart"
phx-hook="ChartJSPressureChart" phx-hook="ChartJSPressureChart"
data-weather-history={@weather_history_json} data-weather-history={@weather_history_json}
style="height:250px;" class="bg-base-200 rounded-lg p-4 mb-4"
>
<canvas></canvas>
</div>
<% end %>
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :wind_direction, nil)))) do %>
<div
id="wind-direction-chart"
phx-hook="ChartJSWindDirectionChart"
data-weather-history={@weather_history_json}
style="height:250px;" style="height:250px;"
> >
<canvas></canvas> <canvas></canvas>
@ -214,9 +198,10 @@
<% end %> <% end %>
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :wind_speed, nil)))) do %> <%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :wind_speed, nil)))) do %>
<div <div
id="wind-speed-chart" id="wind-chart"
phx-hook="ChartJSWindSpeedChart" phx-hook="ChartJSWindChart"
data-weather-history={@weather_history_json} data-weather-history={@weather_history_json}
class="bg-base-200 rounded-lg p-4 mb-4"
style="height:250px;" style="height:250px;"
> >
<canvas></canvas> <canvas></canvas>
@ -227,6 +212,7 @@
id="rain-chart" id="rain-chart"
phx-hook="ChartJSRainChart" phx-hook="ChartJSRainChart"
data-weather-history={@weather_history_json} data-weather-history={@weather_history_json}
class="bg-base-200 rounded-lg p-4 mb-4"
style="height:250px;" style="height:250px;"
> >
<canvas></canvas> <canvas></canvas>
@ -237,6 +223,7 @@
id="luminosity-chart" id="luminosity-chart"
phx-hook="ChartJSLuminosityChart" phx-hook="ChartJSLuminosityChart"
data-weather-history={@weather_history_json} data-weather-history={@weather_history_json}
class="bg-base-200 rounded-lg p-4 mb-4"
style="height:250px;" style="height:250px;"
> >
<canvas></canvas> <canvas></canvas>
@ -253,10 +240,12 @@
</div> </div>
</div> </div>
<% else %> <% else %>
<div class="w-full min-h-screen bg-slate-50 py-8 px-2 md:px-8 flex items-center justify-center"> <div class="min-h-screen bg-base-200 flex items-center justify-center">
<div class="w-full max-w-lg bg-white rounded-xl shadow-lg p-8 text-center"> <div class="card bg-base-100 shadow-xl max-w-lg">
<h1 class="text-2xl font-bold mb-2">Weather for {@callsign}</h1> <div class="card-body text-center">
<div class="text-gray-600">No recent weather data available for this callsign.</div> <h1 class="text-2xl font-bold mb-2">Weather for {@callsign}</h1>
<div class="opacity-70">No recent weather data available for this callsign.</div>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>