First commit for bicorder-app

This commit is contained in:
Nathan Schneider
2025-11-25 13:20:21 -05:00
parent 3a55d3dbb9
commit b541f6049e
24 changed files with 8883 additions and 0 deletions

View File

@@ -0,0 +1,368 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { AnalysisGradient } from '../types';
import Tooltip from './Tooltip.svelte';
export let gradient: AnalysisGradient;
const dispatch = createEventDispatcher<{
change: number | null;
notes: string;
}>();
let showNotes = false;
let notesText = gradient.notes || '';
function handleNotApplicable() {
dispatch('change', null);
}
function handleNotesSave() {
dispatch('notes', notesText);
showNotes = false;
}
function handleScaleClick(event: MouseEvent) {
if (gradient.automated) return;
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function handleScaleTouch(event: TouchEvent) {
if (gradient.automated) return;
event.preventDefault();
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const touch = event.touches[0];
const x = touch.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function renderBar(value: number | null): string {
// Fixed scale with 9 positions using ||||#||||
if (value === null) {
return '||||·||||';
}
// Value is 1-9, position the # marker at the right spot
const positions = [
'#||||||||',
'|#|||||||',
'||#||||||',
'|||#|||||',
'||||#||||',
'|||||#|||',
'||||||#||',
'|||||||#|',
'||||||||#'
];
return positions[value - 1];
}
$: barDisplay = renderBar(gradient.value);
</script>
<div class="analysis-gradient">
<div class="gradient-row">
<div class="term left">
<Tooltip text={gradient.term_left_description}>
{gradient.term_left}
</Tooltip>
</div>
<div class="bar-container">
<button
class="bar"
class:interactive={!gradient.automated}
class:automated={gradient.automated}
on:click={handleScaleClick}
on:touchstart={handleScaleTouch}
role={gradient.automated ? 'img' : 'slider'}
aria-label={gradient.automated ? `Analysis showing value ${gradient.value || 'not calculated'}` : `Gradient scale between ${gradient.term_left} and ${gradient.term_right}`}
aria-valuemin={gradient.automated ? undefined : 1}
aria-valuemax={gradient.automated ? undefined : 9}
aria-valuenow={gradient.automated ? undefined : gradient.value}
title={gradient.automated ? 'Auto-calculated' : 'Click or tap on the scale to set value'}
disabled={gradient.automated}
>
{barDisplay}
</button>
</div>
<div class="term right">
<Tooltip text={gradient.term_right_description}>
{gradient.term_right}
</Tooltip>
</div>
</div>
<div class="value-display">
Value: {gradient.value !== null ? gradient.value : ''}
{#if gradient.automated}
<span class="auto-label">(auto-calculated)</span>
{/if}
</div>
<div class="controls">
{#if !gradient.automated}
<div class="button-row">
<button
class="na-btn"
class:active={gradient.value === null}
on:click={handleNotApplicable}
aria-label="Mark as not applicable"
title="Mark this gradient as not applicable"
>
N/A
</button>
<button
class="notes-btn"
class:has-notes={gradient.notes}
on:click={() => showNotes = !showNotes}
aria-label="Add notes"
>
{gradient.notes ? '📝' : ''} Notes
</button>
</div>
{:else}
<button
class="notes-btn"
class:has-notes={gradient.notes}
on:click={() => showNotes = !showNotes}
aria-label="Add notes"
>
{gradient.notes ? '📝' : ''} Notes
</button>
{/if}
</div>
{#if showNotes}
<div class="notes-editor">
<textarea
bind:value={notesText}
placeholder="Add notes or reference..."
rows="2"
/>
<div class="notes-buttons">
<button on:click={handleNotesSave}>Save</button>
<button on:click={() => showNotes = false}>Cancel</button>
</div>
</div>
{/if}
</div>
<style>
.analysis-gradient {
margin: 1rem 0;
padding: 0.5rem;
}
.gradient-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.term {
text-align: center;
font-size: 0.9rem;
}
.term.left {
text-align: right;
}
.term.right {
text-align: left;
}
.bar-container {
display: flex;
justify-content: center;
}
.bar {
font-family: 'Courier New', Courier, monospace;
font-size: 1.8rem;
white-space: nowrap;
letter-spacing: 0.2rem;
padding: 0.5rem 1rem;
user-select: none;
min-width: 240px;
text-align: center;
background-color: var(--bg-color);
color: var(--fg-color);
border: 2px solid var(--border-color);
}
.bar.automated {
cursor: default;
opacity: 0.6;
}
.bar.interactive {
cursor: pointer;
transition: border-color 0.2s;
}
.bar.interactive:hover {
border-color: var(--hover-color);
}
.bar.interactive:active {
background-color: var(--input-bg);
border-color: var(--fg-color);
}
.value-display {
text-align: center;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.auto-label {
opacity: 0.6;
font-size: 0.8rem;
font-style: italic;
}
.controls {
display: flex;
justify-content: center;
margin-top: 0.5rem;
}
.button-row {
display: flex;
gap: 0.5rem;
width: auto;
}
.na-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
flex-shrink: 0;
min-height: auto;
width: auto;
}
.na-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.na-btn.active {
opacity: 1;
background-color: var(--input-bg);
border-color: var(--fg-color);
}
.notes-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
min-height: auto;
width: auto;
}
.notes-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.notes-btn.has-notes {
opacity: 1;
border-color: var(--hover-color);
}
.notes-btn.has-notes:hover {
background-color: transparent;
color: var(--fg-color);
}
.notes-editor {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border-color);
}
.notes-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.notes-buttons button {
flex: 1;
}
@media (max-width: 768px) {
.gradient-row {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 0.5rem;
}
.term.left {
text-align: left;
grid-row: 1;
}
.bar-container {
grid-row: 2;
}
.term.right {
text-align: right;
grid-row: 3;
}
.bar {
font-size: 1.4rem;
min-width: 200px;
padding: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { BicorderState } from '../types';
export let data: BicorderState;
const dispatch = createEventDispatcher<{
reset: void;
}>();
let showUploadDialog = false;
let isUploading = false;
// Gitea configuration
const GITEA_TOKEN = 'd495e72e955c00be2de0f1e18183f6a385b6e52c';
const GITEA_API_URL = 'https://git.medlab.host/api/v1';
const REPO_OWNER = 'ntnsndr';
const REPO_NAME = 'protocol-bicorder-data';
const REPO_URL = `https://git.medlab.host/${REPO_OWNER}/${REPO_NAME}`;
const APP_VERSION = '1.0.0';
function exportToJSON() {
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `bicorder-${data.metadata.protocol || 'diagnostic'}-${new Date().toISOString().slice(0, 10)}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function shareResults() {
const json = JSON.stringify(data, null, 2);
const file = new File([json], `bicorder-${data.metadata.protocol || 'diagnostic'}.json`, {
type: 'application/json',
});
if (navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
try {
await navigator.share({
title: 'Protocol Bicorder Diagnostic',
text: `Diagnostic for: ${data.metadata.protocol || 'Protocol'}`,
files: [file],
});
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
alert('Share failed. Try using the Export button instead.');
}
}
} else {
alert('Web Share API not supported. Use the Export button to download the file.');
}
}
async function uploadReadings() {
isUploading = true;
try {
// Generate filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5) + 'Z';
const filename = `bicorder-${timestamp}.json`;
const filepath = `readings/${filename}`;
// Create commit message
const protocolName = data.metadata.protocol || 'Unknown Protocol';
const analystName = data.metadata.analyst || 'Anonymous';
const commitMessage = `Bicorder reading: ${protocolName} by ${analystName} | Source: Protocol Bicorder v${APP_VERSION}`;
// Prepare the content (base64 encoded)
const jsonContent = JSON.stringify(data, null, 2);
const base64Content = btoa(unescape(encodeURIComponent(jsonContent)));
// Upload to Gitea
const response = await fetch(
`${GITEA_API_URL}/repos/${REPO_OWNER}/${REPO_NAME}/contents/${filepath}`,
{
method: 'POST',
mode: 'cors',
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
content: base64Content,
message: commitMessage,
branch: 'main',
}),
}
);
if (response.ok) {
alert('Successfully uploaded! Your reading is now public.');
showUploadDialog = false;
} else {
const errorData = await response.json();
throw new Error(errorData.message || `Upload failed: ${response.statusContents}`);
}
} catch (err) {
console.error('Upload error:', err);
alert(`Upload error: ${(err as Error).message}`);
} finally {
isUploading = false;
}
}
function handleReset() {
dispatch('reset');
}
</script>
<section class="export-controls">
<div class="button-group">
<button on:click={exportToJSON}>
💾 Export JSON
</button>
{#if navigator.share}
<button on:click={shareResults}>
📤 Share
</button>
{/if}
<button on:click={() => showUploadDialog = !showUploadDialog}>
📤 Upload
</button>
<button class="reset-btn" on:click={handleReset}>
🗑️ Reset All
</button>
</div>
{#if showUploadDialog}
<div class="webhook-config">
<p class="upload-confirmation">
Are you sure you are ready to share your readings publicly?
</p>
<p class="upload-terms">
Submitted readings are posted publicly and licensed to the public domain. By proceeding, you agree to these terms.
</p>
<p class="upload-repo-link">
Readings are posted to <a href={REPO_URL} target="_blank" rel="noopener noreferrer">this repository</a>.
</p>
<div class="webhook-buttons">
<button on:click={uploadReadings} disabled={isUploading}>
{isUploading ? 'Uploading...' : 'Proceed'}
</button>
<button on:click={() => showUploadDialog = false} disabled={isUploading}>Cancel</button>
</div>
</div>
{/if}
</section>
<style>
.export-controls {
margin: 2rem 0;
padding: 1rem;
border-top: 2px solid var(--border-color);
}
.button-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.reset-btn {
border-color: #ff0000;
color: #ff0000;
}
.reset-btn:hover {
background-color: #ff0000;
color: #ffffff;
}
.webhook-config {
margin-top: 1rem;
padding: 1rem;
border: 2px solid var(--border-color);
}
.upload-confirmation {
font-size: 1rem;
font-weight: bold;
margin-bottom: 1rem;
text-align: center;
}
.upload-terms {
font-size: 0.85rem;
opacity: 0.7;
line-height: 1.4;
margin-bottom: 1rem;
text-align: center;
}
.upload-repo-link {
font-size: 0.9rem;
margin-bottom: 1rem;
text-align: center;
}
.upload-repo-link a {
color: var(--fg-color);
text-decoration: underline;
}
.upload-repo-link a:hover {
opacity: 0.7;
}
.webhook-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.webhook-buttons button {
flex: 1;
}
@media (max-width: 768px) {
.button-group {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,336 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Gradient } from '../types';
import Tooltip from './Tooltip.svelte';
export let gradient: Gradient;
const dispatch = createEventDispatcher<{
change: number;
notes: string;
}>();
let showNotes = false;
let notesText = gradient.notes || '';
function handleNotApplicable() {
dispatch('change', null);
}
function handleNotesSave() {
dispatch('notes', notesText);
showNotes = false;
}
function handleScaleClick(event: MouseEvent) {
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const x = event.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function handleScaleTouch(event: TouchEvent) {
event.preventDefault();
const target = event.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const touch = event.touches[0];
const x = touch.clientX - rect.left;
const width = rect.width;
// Account for button padding to get actual text area
const styles = window.getComputedStyle(target);
const paddingLeft = parseFloat(styles.paddingLeft);
const paddingRight = parseFloat(styles.paddingRight);
const contentWidth = width - paddingLeft - paddingRight;
const xInContent = x - paddingLeft;
// Clamp to content area and get ratio
const ratio = Math.max(0, Math.min(1, xInContent / contentWidth));
// Map to 1-9 with proper centering on each character position
const value = Math.min(9, Math.max(1, Math.floor(ratio * 9) + 1));
dispatch('change', value);
}
function renderBar(value: number | null): string {
// Fixed scale with 9 positions using ||||#||||
if (value === null) {
return '||||·||||';
}
// Value is 1-9, position the # marker at the right spot
const positions = [
'#||||||||',
'|#|||||||',
'||#||||||',
'|||#|||||',
'||||#||||',
'|||||#|||',
'||||||#||',
'|||||||#|',
'||||||||#'
];
return positions[value - 1];
}
$: barDisplay = renderBar(gradient.value);
</script>
<div class="gradient-container">
<div class="gradient-row">
<div class="term left">
<Tooltip text={gradient.term_left_description}>
{gradient.term_left}
</Tooltip>
</div>
<div class="bar-container">
<button
class="bar"
on:click={handleScaleClick}
on:touchstart={handleScaleTouch}
role="slider"
aria-label="Gradient scale between {gradient.term_left} and {gradient.term_right}"
aria-valuemin="1"
aria-valuemax="9"
aria-valuenow={gradient.value}
title="Click or tap on the scale to set value"
>
{barDisplay}
</button>
</div>
<div class="term right">
<Tooltip text={gradient.term_right_description}>
{gradient.term_right}
</Tooltip>
</div>
</div>
<div class="value-display">
Value: {gradient.value !== null ? gradient.value : ''}
</div>
<div class="controls">
<div class="button-row">
<button
class="na-btn"
class:active={gradient.value === null}
on:click={handleNotApplicable}
aria-label="Mark as not applicable"
title="Mark this gradient as not applicable"
>
N/A
</button>
<button
class="notes-btn"
class:has-notes={gradient.notes}
on:click={() => showNotes = !showNotes}
aria-label="Add notes"
>
{gradient.notes ? '📝' : ''} Notes
</button>
</div>
</div>
{#if showNotes}
<div class="notes-editor">
<textarea
bind:value={notesText}
placeholder="Add notes or reference..."
rows="2"
/>
<div class="notes-buttons">
<button on:click={handleNotesSave}>Save</button>
<button on:click={() => showNotes = false}>Cancel</button>
</div>
</div>
{/if}
</div>
<style>
.gradient-container {
margin: 1rem 0;
padding: 0.5rem;
}
.gradient-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.term {
text-align: center;
font-size: 0.9rem;
}
.term.left {
text-align: right;
}
.term.right {
text-align: left;
}
.bar-container {
display: flex;
justify-content: center;
}
.bar {
font-family: 'Courier New', Courier, monospace;
font-size: 1.8rem;
white-space: nowrap;
letter-spacing: 0.2rem;
padding: 0.5rem 1rem;
cursor: pointer;
user-select: none;
min-width: 240px;
text-align: center;
background-color: var(--bg-color);
color: var(--fg-color);
border: 2px solid var(--border-color);
transition: border-color 0.2s;
}
.bar:hover {
border-color: var(--hover-color);
background-color: var(--bg-color);
color: var(--fg-color);
}
.bar:active {
background-color: var(--input-bg);
border-color: var(--fg-color);
color: var(--fg-color);
}
.value-display {
text-align: center;
margin: 0.5rem 0;
font-size: 0.9rem;
}
.controls {
margin-top: 0.5rem;
display: flex;
justify-content: center;
}
.button-row {
display: flex;
gap: 0.5rem;
width: auto;
}
.na-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
flex-shrink: 0;
min-height: auto;
width: auto;
}
.na-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.na-btn.active {
opacity: 1;
background-color: var(--input-bg);
border-color: var(--fg-color);
}
.notes-btn {
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
opacity: 0.6;
min-height: auto;
width: auto;
}
.notes-btn:hover {
opacity: 1;
background-color: transparent;
color: var(--fg-color);
border-color: var(--hover-color);
}
.notes-btn.has-notes {
opacity: 1;
border-color: var(--hover-color);
}
.notes-btn.has-notes:hover {
background-color: transparent;
color: var(--fg-color);
}
.notes-editor {
margin-top: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--border-color);
}
.notes-buttons {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
.notes-buttons button {
flex: 1;
}
@media (max-width: 768px) {
.gradient-row {
grid-template-columns: 1fr;
grid-template-rows: auto auto auto;
gap: 0.5rem;
}
.term.left {
text-align: left;
grid-row: 1;
}
.bar-container {
grid-row: 2;
}
.term.right {
text-align: right;
grid-row: 3;
}
.bar {
font-size: 1.4rem;
min-width: 200px;
padding: 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,216 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import type { Metadata } from '../types';
export let metadata: Metadata;
const dispatch = createEventDispatcher<{
update: Partial<Metadata>;
}>();
function handleInput(field: keyof Metadata, value: string) {
dispatch('update', { [field]: value || null });
}
function handleToggle(field: keyof Metadata, value: boolean) {
dispatch('update', { [field]: value });
}
function updateTimestamp() {
dispatch('update', { timestamp: new Date().toISOString() });
}
function formatTimestamp(timestamp: string | null): string {
if (!timestamp) return '';
try {
return new Date(timestamp).toLocaleString();
} catch {
return timestamp;
}
}
</script>
<section class="metadata">
<div class="metadata-field">
<label for="protocol">Protocol:</label>
<input
id="protocol"
type="text"
placeholder="[Protocol]"
value={metadata.protocol || ''}
on:input={(e) => handleInput('protocol', e.currentTarget.value)}
/>
</div>
<div class="metadata-field">
<label for="analyst">Analyst:</label>
<input
id="analyst"
type="text"
placeholder="[Analyst]"
value={metadata.analyst || ''}
on:input={(e) => handleInput('analyst', e.currentTarget.value)}
/>
</div>
<div class="metadata-field">
<label for="standpoint">Standpoint:</label>
<input
id="standpoint"
type="text"
placeholder="[Standpoint]"
value={metadata.standpoint || ''}
on:input={(e) => handleInput('standpoint', e.currentTarget.value)}
/>
</div>
<div class="metadata-field">
<label for="timestamp">Timestamp:</label>
<div class="timestamp-display">
<input
id="timestamp"
type="text"
readonly
value={formatTimestamp(metadata.timestamp)}
placeholder="[Auto-generated]"
/>
<button on:click={updateTimestamp} aria-label="Update timestamp">
🕐 Update
</button>
</div>
</div>
<div class="metadata-field toggle-field">
<label for="shortform">Short Form:</label>
<div class="toggle-container">
<label class="toggle-switch">
<input
id="shortform"
type="checkbox"
checked={metadata.shortform}
on:change={(e) => handleToggle('shortform', e.currentTarget.checked)}
/>
<span class="slider"></span>
</label>
<span class="toggle-label">{metadata.shortform ? 'Enabled' : 'Disabled'}</span>
</div>
</div>
</section>
<style>
.metadata {
margin: 2rem 0;
padding: 1rem;
border: 2px solid var(--border-color);
}
.metadata-field {
margin: 0.5rem 0;
}
label {
display: block;
margin-bottom: 0.25rem;
font-weight: bold;
}
.timestamp-display {
display: flex;
gap: 0.5rem;
}
.timestamp-display input {
flex: 1;
opacity: 0.7;
}
.timestamp-display button {
flex-shrink: 0;
}
.toggle-field {
display: flex;
align-items: center;
gap: 1rem;
}
.toggle-field > label {
margin-bottom: 0;
}
.toggle-container {
display: flex;
align-items: center;
gap: 0.75rem;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
margin-bottom: 0;
cursor: pointer;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 26px;
transition: 0.3s;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
transition: 0.3s;
}
.toggle-switch input:checked + .slider {
background-color: var(--primary-color, #4CAF50);
}
.toggle-switch input:checked + .slider:before {
transform: translateX(24px);
}
.toggle-label {
font-size: 0.9rem;
opacity: 0.8;
}
@media (max-width: 768px) {
.metadata {
padding: 0.5rem;
}
.timestamp-display {
flex-direction: column;
}
.timestamp-display button {
width: 100%;
}
.toggle-field {
flex-direction: column;
align-items: flex-start;
}
}
</style>

View File

@@ -0,0 +1,120 @@
<script lang="ts">
export let text: string;
let showTooltip = false;
let touchTimer: number;
let isLongPress = false;
function handleTouchStart() {
isLongPress = false;
touchTimer = window.setTimeout(() => {
showTooltip = true;
isLongPress = true;
}, 500); // Show after 500ms press
}
function handleTouchEnd() {
clearTimeout(touchTimer);
if (isLongPress) {
// Keep tooltip visible for a moment after long press release
setTimeout(() => {
showTooltip = false;
}, 2000);
}
}
function handleClick() {
// Toggle tooltip on click/tap
if (!isLongPress) {
showTooltip = !showTooltip;
// Auto-hide after 3 seconds
if (showTooltip) {
setTimeout(() => {
showTooltip = false;
}, 3000);
}
}
isLongPress = false;
}
function handleMouseEnter() {
showTooltip = true;
}
function handleMouseLeave() {
showTooltip = false;
}
</script>
<span
class="tooltip-wrapper"
on:mouseenter={handleMouseEnter}
on:mouseleave={handleMouseLeave}
on:touchstart={handleTouchStart}
on:touchend={handleTouchEnd}
on:touchcancel={handleTouchEnd}
on:click={handleClick}
role="button"
tabindex="0"
aria-label={text}
aria-expanded={showTooltip}
>
<slot />
{#if showTooltip}
<span class="tooltip-text" role="status" aria-live="polite">
{text}
</span>
{/if}
</span>
<style>
.tooltip-wrapper {
position: relative;
display: inline-block;
cursor: help;
border-bottom: 1px dotted var(--fg-color);
user-select: none;
}
.tooltip-text {
position: absolute;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
background-color: var(--bg-color);
color: var(--fg-color);
border: 2px solid var(--border-color);
padding: 0.5rem;
z-index: 1000;
width: max-content;
max-width: 300px;
font-size: 0.85rem;
line-height: 1.4;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
@media (prefers-color-scheme: dark) {
.tooltip-text {
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.2);
}
}
.tooltip-text::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border-width: 5px;
border-style: solid;
border-color: var(--border-color) transparent transparent transparent;
}
@media (max-width: 768px) {
.tooltip-text {
max-width: 250px;
font-size: 0.8rem;
}
}
</style>