diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..aae7ec5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# Dependencies +node_modules +npm-debug.log +# Note: package-lock.json is needed for npm ci, so don't exclude it + +# Build outputs +build +.svelte-kit +package + +# Development files +.env +.env.* +!.env.example + +# Git +.git +.gitignore +.gitattributes + +# IDE +.vscode +.idea +*.swp +*.swo +*~ + +# Documentation +README.md +README_SETUP.md +*.md + +# CI/CD +.github +.gitlab-ci.yml + +# Test files +**/*.test.ts +**/*.test.js +**/*.spec.ts +**/*.spec.js +coverage + +# Misc +.DS_Store +*.log +*.tmp diff --git a/Dockerfile b/Dockerfile index eb4ca2e..d7a4734 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,14 +4,12 @@ WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . +# Optional build args - will use defaults from config.ts if not provided +# If ARG is not provided, ENV will be empty and config.ts will use defaults ARG VITE_DEFAULT_RELAYS -ARG VITE_ZAP_THRESHOLD ARG VITE_THREAD_TIMEOUT_DAYS -ARG VITE_PWA_ENABLED ENV VITE_DEFAULT_RELAYS=${VITE_DEFAULT_RELAYS} -ENV VITE_ZAP_THRESHOLD=${VITE_ZAP_THRESHOLD} ENV VITE_THREAD_TIMEOUT_DAYS=${VITE_THREAD_TIMEOUT_DAYS} -ENV VITE_PWA_ENABLED=${VITE_PWA_ENABLED} RUN npm run build FROM httpd:alpine @@ -19,8 +17,20 @@ RUN apk add --no-cache gettext && \ mkdir -p /usr/local/apache2/logs && \ chown -R daemon:daemon /usr/local/apache2/logs COPY --from=builder /app/build /usr/local/apache2/htdocs/ -# Ensure healthz.json exists (copy from public if not in build, or create if missing) -COPY --from=builder /app/public/healthz.json /usr/local/apache2/htdocs/healthz.json +# Ensure healthz.json exists (SvelteKit copies public/healthz.json to build/) +# If it doesn't exist for some reason, create a default one +RUN if [ ! -f /usr/local/apache2/htdocs/healthz.json ]; then \ + echo '{"status":"ok","service":"aitherboard","version":"unknown","buildTime":"'$(date -Iseconds)'","timestamp":'$(date +%s)'}' > /usr/local/apache2/htdocs/healthz.json && \ + echo "Created default healthz.json"; \ + else \ + echo "healthz.json found in build output"; \ + fi +# Verify 200.html exists (required for SPA routing) +RUN if [ ! -f /usr/local/apache2/htdocs/200.html ]; then \ + echo "ERROR: 200.html not found! SPA routing will not work." && exit 1; \ + else \ + echo "200.html found - SPA routing configured correctly"; \ + fi COPY httpd.conf.template /usr/local/apache2/conf/httpd.conf.template COPY docker-entrypoint.sh /usr/local/bin/ RUN chmod +x /usr/local/bin/docker-entrypoint.sh diff --git a/docker-compose.yml b/docker-compose.yml index a4c7413..41a0908 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,12 +1,13 @@ services: aitherboard: + container_name: aitherboard build: context: . - args: - VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land,wss://orly-relay.imwald.eu" - VITE_ZAP_THRESHOLD: "1" - VITE_THREAD_TIMEOUT_DAYS: "30" - VITE_PWA_ENABLED: "true" + # Optional: override defaults from config.ts if needed + # Uncomment and modify if you want custom values: + # args: + # VITE_DEFAULT_RELAYS: "wss://theforest.nostr1.com,wss://nostr21.com,wss://nostr.land" + # VITE_THREAD_TIMEOUT_DAYS: "30" ports: - "9876:9876" environment: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 4283ae0..7a049f0 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -22,10 +22,21 @@ ls -la /usr/local/apache2/htdocs/ | head -20 echo "File count: $(find /usr/local/apache2/htdocs -type f | wc -l)" echo "Checking if port $PORT is available..." -if ! netstat -tuln 2>/dev/null | grep -q ":$PORT "; then - echo "Port $PORT appears to be available" +# Use ss (socket statistics) which is available in Alpine, fallback to netstat if available +if command -v ss >/dev/null 2>&1; then + if ! ss -tuln 2>/dev/null | grep -q ":$PORT "; then + echo "Port $PORT appears to be available" + else + echo "WARNING: Port $PORT might be in use" + fi +elif command -v netstat >/dev/null 2>&1; then + if ! netstat -tuln 2>/dev/null | grep -q ":$PORT "; then + echo "Port $PORT appears to be available" + else + echo "WARNING: Port $PORT might be in use" + fi else - echo "WARNING: Port $PORT might be in use" + echo "Port check skipped (ss/netstat not available)" fi echo "Starting Apache on port $PORT..." diff --git a/httpd.conf.template b/httpd.conf.template index e6232e9..093f561 100644 --- a/httpd.conf.template +++ b/httpd.conf.template @@ -6,6 +6,7 @@ LoadModule log_config_module modules/mod_log_config.so LoadModule unixd_module modules/mod_unixd.so LoadModule dir_module modules/mod_dir.so LoadModule deflate_module modules/mod_deflate.so +LoadModule setenvif_module modules/mod_setenvif.so PidFile "/usr/local/apache2/logs/httpd.pid" ErrorLog "/proc/self/fd/2" diff --git a/src/app.css b/src/app.css index 5fe0e1b..ce35304 100644 --- a/src/app.css +++ b/src/app.css @@ -13,15 +13,15 @@ /* Base text size preferences - will be overridden by media queries if not specified */ [data-text-size='small'] { - --text-size: 10px; + --text-size: 6px; } [data-text-size='medium'] { - --text-size: 12px; + --text-size: 8px; } [data-text-size='large'] { - --text-size: 14px; + --text-size: 10px; } [data-line-spacing='tight'] { diff --git a/src/lib/components/layout/ProfileBadge.svelte b/src/lib/components/layout/ProfileBadge.svelte index 18f0bfd..407452a 100644 --- a/src/lib/components/layout/ProfileBadge.svelte +++ b/src/lib/components/layout/ProfileBadge.svelte @@ -172,8 +172,8 @@ }} onload={(e) => { // If compressed URL fails, try original as fallback - if (imageError && compressedPictureUrl !== profile.picture) { - const img = e.currentTarget; + if (imageError && profile?.picture && compressedPictureUrl !== profile.picture) { + const img = e.currentTarget as HTMLImageElement; img.src = profile.picture; imageError = false; } @@ -192,12 +192,12 @@ {/if} {#if !pictureOnly}
+
+
+
{getRelativeTime()}
- {#if fullView}
-
- {:else}
-
- {/if}
+
{#if !fullView}
{#if loadingStats}
Loading stats...
@@ -373,6 +364,19 @@
.thread-card {
max-width: var(--content-width);
position: relative;
+ overflow: hidden;
+ }
+
+ @media (max-width: 768px) {
+ .thread-card {
+ max-width: 100%;
+ }
+ }
+
+ @media (max-width: 640px) {
+ .thread-card {
+ padding: 0.75rem;
+ }
}
.card-link {
@@ -470,6 +474,11 @@
.thread-stats {
margin-bottom: 0.25rem; /* Decreased space between count row and kind badge */
}
+
+ h3 {
+ word-break: break-word;
+ overflow-wrap: break-word;
+ }
}
:global(.dark) .kind-badge {
diff --git a/src/lib/modules/discussions/DiscussionList.svelte b/src/lib/modules/discussions/DiscussionList.svelte
index 24130a3..dffef14 100644
--- a/src/lib/modules/discussions/DiscussionList.svelte
+++ b/src/lib/modules/discussions/DiscussionList.svelte
@@ -640,6 +640,13 @@
padding: 1rem;
}
+ @media (max-width: 768px) {
+ .thread-list {
+ max-width: 100%;
+ padding: 0.5rem;
+ }
+ }
+
.thread-wrapper {
cursor: pointer;
transition: background 0.2s;
diff --git a/src/lib/modules/discussions/DiscussionView.svelte b/src/lib/modules/discussions/DiscussionView.svelte
index 1d84653..331f6f9 100644
--- a/src/lib/modules/discussions/DiscussionView.svelte
+++ b/src/lib/modules/discussions/DiscussionView.svelte
@@ -154,6 +154,13 @@
padding: 1rem;
}
+ @media (max-width: 768px) {
+ .thread-view {
+ max-width: 100%;
+ padding: 0.5rem;
+ }
+ }
+
.op-section {
margin-bottom: 2rem;
padding-bottom: 1rem;
diff --git a/src/lib/modules/feed/FeedPost.svelte b/src/lib/modules/feed/FeedPost.svelte
index c2a457c..e8481bc 100644
--- a/src/lib/modules/feed/FeedPost.svelte
+++ b/src/lib/modules/feed/FeedPost.svelte
@@ -183,7 +183,7 @@
// Parse NIP-21 links and create segments for rendering
interface ContentSegment {
- type: 'text' | 'profile' | 'event' | 'url' | 'wikilink' | 'hashtag';
+ type: 'text' | 'profile' | 'event' | 'url' | 'wikilink' | 'hashtag' | 'greentext';
content: string; // Display text (without nostr: prefix for links)
pubkey?: string; // For profile badges
eventId?: string; // For event links (bech32 or hex)
@@ -192,6 +192,43 @@
hashtag?: string; // For hashtag topic name
}
+ // Process text to detect greentext (lines starting with >)
+ function processGreentext(text: string): ContentSegment[] {
+ const lines = text.split('\n');
+ const segments: ContentSegment[] = [];
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+ const trimmed = line.trimStart();
+
+ // Check if line starts with > (greentext)
+ if (trimmed.startsWith('>') && trimmed.length > 1) {
+ // Preserve leading whitespace before >
+ const leadingWhitespace = line.substring(0, line.length - trimmed.length);
+ segments.push({
+ type: 'greentext',
+ content: leadingWhitespace + trimmed
+ });
+ } else {
+ // Regular text line
+ segments.push({
+ type: 'text',
+ content: line
+ });
+ }
+
+ // Add newline between lines (except for last line)
+ if (i < lines.length - 1) {
+ segments.push({
+ type: 'text',
+ content: '\n'
+ });
+ }
+ }
+
+ return segments;
+ }
+
function parseContentWithNIP21Links(): ContentSegment[] {
const plaintext = getPlaintextContent();
const links = findNIP21Links(plaintext);
@@ -442,6 +479,20 @@
}
}
+ // Process greentext on final text segments (only in feed view)
+ if (!fullView && finalSegments.length > 0) {
+ const processedSegments: ContentSegment[] = [];
+ for (const segment of finalSegments) {
+ if (segment.type === 'text') {
+ const greentextSegments = processGreentext(segment.content);
+ processedSegments.push(...greentextSegments);
+ } else {
+ processedSegments.push(segment);
+ }
+ }
+ return processedSegments.length > 0 ? processedSegments : finalSegments;
+ }
+
return finalSegments.length > 0 ? finalSegments : segments;
}
@@ -782,13 +833,13 @@
{@const title = getTitle()}
{#if !hideTitle && title && title !== 'Untitled'}
-
{getTitle()}
-+
-
+
@@ -848,7 +899,7 @@
{:else}
-
+
@@ -872,7 +923,7 @@
{@const title = getTitle()}
{#if title && title !== 'Untitled'}
-
{title}
{/if}+
{:else if segment.type === 'event' && segment.eventId}
@@ -986,6 +1039,11 @@
{/if}
{#if !fullView}
+
+
+
+
+
{#if isLoggedIn && bookmarked}
@@ -1051,6 +1109,13 @@
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
position: relative;
+ overflow: hidden;
+ }
+
+ @media (max-width: 640px) {
+ .Feed-post {
+ padding: 0.75rem;
+ }
}
.Feed-post.collapsed {
@@ -1150,6 +1215,20 @@
border-top-color: var(--fog-dark-border, #374151);
}
+ .feed-card-actions-section {
+ margin-top: 0.5rem;
+ padding-top: 0.5rem;
+ border-top: 1px solid var(--fog-border, #e5e7eb);
+ }
+
+ :global(.dark) .feed-card-actions-section {
+ border-top-color: var(--fog-dark-border, #374151);
+ }
+
+ .feed-card-reactions {
+ margin-bottom: 0.5rem;
+ }
+
.feed-card-footer {
margin-top: 0.5rem;
padding-top: 0.5rem;
@@ -1207,6 +1286,34 @@
align-items: center;
line-height: 1.5;
position: relative;
+ gap: 0.5rem;
+ min-width: 0;
+ flex-wrap: wrap;
+ }
+
+ .post-header-left {
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ @media (max-width: 640px) {
+ .post-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ }
+
+ .post-header-left {
+ width: 100%;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ }
+
+ .post-header-actions {
+ width: 100%;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ }
}
.post-header-divider {
@@ -1243,9 +1350,9 @@
flex-wrap: wrap;
}
- .post-header {
- flex-wrap: wrap;
- gap: 0.5rem;
+ .post-title {
+ word-break: break-word;
+ overflow-wrap: break-word;
}
}
@@ -1292,6 +1399,14 @@
background-color: rgba(255, 255, 0, 0.2);
}
+ .greentext {
+ color: #789922;
+ }
+
+ :global(.dark) .greentext {
+ color: #8ab378;
+ }
+
/* Focusable wrapper for keyboard navigation */
div[role="button"] {
outline: none;
diff --git a/src/lib/modules/feed/HighlightCard.svelte b/src/lib/modules/feed/HighlightCard.svelte
index 7789ac8..9f5f584 100644
--- a/src/lib/modules/feed/HighlightCard.svelte
+++ b/src/lib/modules/feed/HighlightCard.svelte
@@ -2,6 +2,7 @@
import ProfileBadge from '../../components/layout/ProfileBadge.svelte';
import MarkdownRenderer from '../../components/content/MarkdownRenderer.svelte';
import EventMenu from '../../components/EventMenu.svelte';
+ import FeedReactionButtons from '../reactions/FeedReactionButtons.svelte';
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { onMount } from 'svelte';
@@ -376,6 +377,10 @@
{/if}
+
+
+
+
{getKindInfo(highlight.kind).number}
{getKindInfo(highlight.kind).description}
@@ -431,6 +436,17 @@
border-top-color: var(--fog-dark-border, #374151);
}
+ .highlight-actions {
+ padding-top: 0.5rem;
+ padding-right: 6rem; /* Reserve space for kind badge */
+ border-top: 1px solid var(--fog-border, #e5e7eb);
+ margin-top: 0.5rem;
+ }
+
+ :global(.dark) .highlight-actions {
+ border-top-color: var(--fog-dark-border, #374151);
+ }
+
.source-link {
color: var(--fog-accent, #64748b);
text-decoration: none;
diff --git a/src/lib/services/nostr/config.ts b/src/lib/services/nostr/config.ts
index 66e4503..7a56a6d 100644
--- a/src/lib/services/nostr/config.ts
+++ b/src/lib/services/nostr/config.ts
@@ -14,7 +14,6 @@ const PROFILE_RELAYS = [
'wss://aggr.nostr.land',
'wss://profiles.nostr1.com',
'wss://relay.primal.net',
- 'wss://orly-relay.imwald.eu',
'wss://nostr.wine',
'wss://nostr21.com'
];
diff --git a/src/routes/discussions/+page.svelte b/src/routes/discussions/+page.svelte
index 0ee134c..d2364eb 100644
--- a/src/routes/discussions/+page.svelte
+++ b/src/routes/discussions/+page.svelte
@@ -129,6 +129,12 @@
margin: 0 auto;
}
+ @media (max-width: 768px) {
+ .discussions-content {
+ max-width: 100%;
+ }
+ }
+
.discussions-header-sticky {
padding: 0 1rem;
padding-top: 1rem;
diff --git a/src/routes/feed/+page.svelte b/src/routes/feed/+page.svelte
index 5acc3c3..ca6a78f 100644
--- a/src/routes/feed/+page.svelte
+++ b/src/routes/feed/+page.svelte
@@ -166,8 +166,29 @@
gap: 1rem;
}
+ @media (max-width: 640px) {
+ .feed-controls {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.75rem;
+ }
+ }
+
.search-section {
flex: 1;
+ min-width: 0;
+ }
+
+ @media (max-width: 640px) {
+ .search-section {
+ width: 100%;
+ flex: none;
+ }
+
+ .search-section :global(.unified-search-container) {
+ max-width: 100%;
+ width: 100%;
+ }
}
.feed-header-buttons {
@@ -175,6 +196,14 @@
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
+ flex-shrink: 0;
+ }
+
+ @media (max-width: 640px) {
+ .feed-header-buttons {
+ width: 100%;
+ justify-content: flex-start;
+ }
}
.see-new-events-btn-header {
diff --git a/src/routes/topics/+page.svelte b/src/routes/topics/+page.svelte
index fd33a3f..a404ef4 100644
--- a/src/routes/topics/+page.svelte
+++ b/src/routes/topics/+page.svelte
@@ -336,6 +336,14 @@
max-width: var(--content-width);
margin: 0 auto;
padding: 0 1rem;
+ overflow: hidden;
+ }
+
+ @media (max-width: 768px) {
+ .topics-page {
+ max-width: 100%;
+ padding: 0 0.5rem;
+ }
}
.filter-section {
@@ -351,6 +359,14 @@
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.75rem;
+ overflow: hidden;
+ }
+
+ @media (max-width: 640px) {
+ .topics-list {
+ grid-template-columns: 1fr;
+ gap: 0.5rem;
+ }
}
.topics-sentinel {
@@ -376,6 +392,14 @@
cursor: pointer;
transition: all 0.2s;
font-family: monospace;
+ overflow: hidden;
+ min-width: 0;
+ }
+
+ @media (max-width: 640px) {
+ .topic-item {
+ padding: 0.5rem 0.75rem;
+ }
}
:global(.dark) .topic-item {
diff --git a/src/routes/topics/[name]/+page.svelte b/src/routes/topics/[name]/+page.svelte
index 48f863b..e462689 100644
--- a/src/routes/topics/[name]/+page.svelte
+++ b/src/routes/topics/[name]/+page.svelte
@@ -230,12 +230,27 @@
.topic-content {
max-width: var(--content-width);
margin: 0 auto;
+ overflow: hidden;
+ }
+
+ @media (max-width: 768px) {
+ .topic-content {
+ max-width: 100%;
+ }
}
.topic-header {
padding: 0 1rem;
border-bottom: 1px solid var(--fog-border, #e5e7eb);
padding-bottom: 1rem;
+ margin-bottom: 1rem;
+ }
+
+ @media (max-width: 640px) {
+ .topic-header {
+ padding: 0 0.5rem;
+ padding-bottom: 0.75rem;
+ }
}
:global(.dark) .topic-header {
@@ -252,6 +267,7 @@
display: flex;
flex-direction: column;
gap: 1rem;
+ overflow: hidden;
}
.event-item {
@@ -259,6 +275,14 @@
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.5rem;
background: var(--fog-post, #ffffff);
+ overflow: hidden;
+ }
+
+ @media (max-width: 640px) {
+ .event-item {
+ padding: 0.5rem;
+ margin: 0 0.5rem;
+ }
}
:global(.dark) .event-item {
{title}
{/if} @@ -904,6 +955,8 @@ {:else} {segment.content} {/if} + {:else if segment.type === 'greentext'} + {segment.content} {:else if segment.type === 'profile' && segment.pubkey}