manual post and nip19 rework, content page looks good for now.

This commit is contained in:
Enki 2025-03-02 21:38:06 -08:00
parent 41ad467618
commit 52d3e4a28d
20 changed files with 704 additions and 153 deletions

Binary file not shown.

View File

@ -2,11 +2,15 @@
package main package main
import ( import (
"context"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"time" "time"
"github.com/nbd-wtf/go-nostr"
"git.sovbit.dev/Enki/nostr-poster/internal/api" "git.sovbit.dev/Enki/nostr-poster/internal/api"
"git.sovbit.dev/Enki/nostr-poster/internal/auth" "git.sovbit.dev/Enki/nostr-poster/internal/auth"
"git.sovbit.dev/Enki/nostr-poster/internal/config" "git.sovbit.dev/Enki/nostr-poster/internal/config"
@ -15,6 +19,7 @@ import (
"git.sovbit.dev/Enki/nostr-poster/internal/media/prepare" "git.sovbit.dev/Enki/nostr-poster/internal/media/prepare"
"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom" "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/blossom"
"git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94" "git.sovbit.dev/Enki/nostr-poster/internal/media/upload/nip94"
"git.sovbit.dev/Enki/nostr-poster/internal/models"
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/events" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/events"
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/poster" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/poster"
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay" "git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay"
@ -146,15 +151,120 @@ func main() {
logger, logger,
) )
// Create post content function // Create standard post content function
postContentFunc := poster.CreatePostContentFunc( postContentFunc := poster.CreatePostContentFunc(
eventManager, eventManager,
relayManager, relayManager,
mediaPrep, mediaPrep,
logger, logger,
) )
// Create the NIP-19 encoded post content function
postContentEncodedFunc := func(
pubkey string,
contentPath string,
contentType string,
mediaURL string,
mediaHash string,
caption string,
hashtags []string,
) (*models.EventResponse, error) {
// Create a context
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Determine the type of content
isImage := strings.HasPrefix(contentType, "image/")
isVideo := strings.HasPrefix(contentType, "video/")
// Create alt text if not provided
altText := caption
if altText == "" {
// Use the filename without extension as a fallback
altText = strings.TrimSuffix(filepath.Base(contentPath), filepath.Ext(contentPath))
}
// Extract media dimensions if it's an image
if isImage {
dims, err := mediaPrep.GetMediaDimensions(contentPath)
if err != nil {
logger.Warn("Failed to get image dimensions, continuing anyway",
zap.String("file", contentPath),
zap.Error(err))
} else {
// Add dimensions to alt text if available
if altText != "" {
altText = fmt.Sprintf("%s [%dx%d]", altText, dims.Width, dims.Height)
}
}
}
// Create an appropriate event
var event *nostr.Event
var err error
if isImage {
// Create hashtag string for post content
var hashtagStr string
if len(hashtags) > 0 {
hashtagArr := make([]string, len(hashtags))
for i, tag := range hashtags {
hashtagArr[i] = "#" + tag
}
hashtagStr = "\n\n" + strings.Join(hashtagArr, " ")
}
content := caption + hashtagStr
event, err = eventManager.CreateAndSignMediaEvent(
pubkey,
content,
mediaURL,
contentType,
mediaHash,
altText,
hashtags,
)
} else if isVideo {
// For videos, determine if it's a short video
isShortVideo := false // Just a placeholder, would need logic to determine
// Create the video event
event, err = eventManager.CreateAndSignVideoEvent(
pubkey,
caption, // Title
caption, // Description
mediaURL,
contentType,
mediaHash,
"", // Preview image URL
0, // Duration
altText,
hashtags,
isShortVideo,
)
} else {
// For other types, use a regular text note with attachment
event, err = eventManager.CreateAndSignMediaEvent(
pubkey,
caption,
mediaURL,
contentType,
mediaHash,
altText,
hashtags,
)
}
if err != nil {
return nil, fmt.Errorf("failed to create event: %w", err)
}
// Publish the event with NIP-19 encoding
return relayManager.PublishEventWithEncoding(ctx, event)
}
// Initialize scheduler // Initialize scheduler with both posting functions
posterScheduler := scheduler.NewScheduler( posterScheduler := scheduler.NewScheduler(
database, database,
logger, logger,
@ -163,6 +273,7 @@ func main() {
nip94Uploader, nip94Uploader,
blossomUploader, blossomUploader,
postContentFunc, postContentFunc,
postContentEncodedFunc, // New encoded function
) )
// Initialize API // Initialize API

Binary file not shown.

After

Width:  |  Height:  |  Size: 971 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 827 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

View File

@ -539,6 +539,38 @@ func (s *BotService) PublishBotProfile(botID int64, ownerPubkey string) error {
return nil return nil
} }
// PublishBotProfileWithEncoding publishes a bot's profile and returns the encoded event
func (s *BotService) PublishBotProfileWithEncoding(botID int64, ownerPubkey string) (*models.EventResponse, error) {
// Get the bot
bot, err := s.GetBotByID(botID, ownerPubkey)
if err != nil {
return nil, fmt.Errorf("bot not found or not owned by user: %w", err)
}
// Create and sign the metadata event
event, err := s.eventMgr.CreateAndSignMetadataEvent(bot)
if err != nil {
return nil, fmt.Errorf("failed to create metadata event: %w", err)
}
// Set up relay connections
for _, relay := range bot.Relays {
if relay.Write {
if err := s.relayMgr.AddRelay(relay.URL, relay.Read, relay.Write); err != nil {
s.logger.Warn("Failed to add relay",
zap.String("url", relay.URL),
zap.Error(err))
}
}
}
// Publish the event with encoding
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
return s.relayMgr.PublishEventWithEncoding(ctx, event)
}
// Helper function to load related data for a bot // Helper function to load related data for a bot
func (s *BotService) loadBotRelatedData(bot *models.Bot) error { func (s *BotService) loadBotRelatedData(bot *models.Bot) error {
// Load post config // Load post config

View File

@ -834,7 +834,7 @@ func (a *API) uploadToMediaServer(c *gin.Context) {
}) })
} }
// Updated createManualPost function in routes.go to properly process kind 20 posts // Updated createManualPost function in routes.go
func (a *API) createManualPost(c *gin.Context) { func (a *API) createManualPost(c *gin.Context) {
pubkey := c.GetString("pubkey") pubkey := c.GetString("pubkey")
botIDStr := c.Param("id") botIDStr := c.Param("id")
@ -883,14 +883,8 @@ func (a *API) createManualPost(c *gin.Context) {
if len(matches) > 0 { if len(matches) > 0 {
mediaURL = matches[0] // Use the first URL found mediaURL = matches[0] // Use the first URL found
// Try to determine media type - this is a placeholder // Try to determine media type
// In a real implementation, you might want to make an HTTP head request
// or infer from URL extension
mediaType = inferMediaTypeFromURL(mediaURL) mediaType = inferMediaTypeFromURL(mediaURL)
// We don't have a hash yet, but we could calculate it if needed
// This would require downloading the file, which might be too heavy
mediaHash = ""
} }
// Create the appropriate event // Create the appropriate event
@ -941,22 +935,22 @@ func (a *API) createManualPost(c *gin.Context) {
} }
} }
// Publish to relays // Publish to relays with NIP-19 encoding
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
publishedRelays, err := a.botService.relayMgr.PublishEvent(ctx, event) // Use the new method that includes NIP-19 encoding
encodedEvent, err := a.botService.relayMgr.PublishEventWithEncoding(ctx, event)
if err != nil { if err != nil {
a.logger.Error("Failed to publish event", zap.Error(err)) a.logger.Error("Failed to publish event", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()})
return return
} }
// Return success // Return the encoded event response
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Post published successfully", "message": "Post published successfully",
"event_id": event.ID, "event": encodedEvent,
"relays": publishedRelays,
}) })
} }

View File

@ -0,0 +1,16 @@
// internal/models/response.go
package models
// EventResponse represents an event with NIP-19 encoded identifiers
type EventResponse struct {
ID string `json:"id"` // Raw hex event ID
Nevent string `json:"nevent"` // NIP-19 encoded event ID with relay info
Note string `json:"note"` // NIP-19 encoded event ID without relay info
Pubkey string `json:"pubkey"` // Raw hex pubkey
Npub string `json:"npub"` // NIP-19 encoded pubkey
CreatedAt int64 `json:"created_at"`
Kind int `json:"kind"`
Content string `json:"content"`
Tags [][]string `json:"tags"`
Relays []string `json:"relays"` // Relays where the event was published
}

View File

@ -0,0 +1,56 @@
// internal/nostr/events/encoder.go
package events
import (
"github.com/nbd-wtf/go-nostr"
"git.sovbit.dev/Enki/nostr-poster/internal/models"
"git.sovbit.dev/Enki/nostr-poster/internal/utils"
)
// EncodeEvent converts a nostr.Event to models.EventResponse with NIP-19 encoded IDs
func EncodeEvent(event *nostr.Event, publishedRelays []string) (*models.EventResponse, error) {
// Create the basic response
response := &models.EventResponse{
ID: event.ID,
Pubkey: event.PubKey,
CreatedAt: int64(event.CreatedAt),
Kind: event.Kind,
Content: event.Content,
Tags: convertTags(event.Tags), // Use a helper function to convert tags
Relays: publishedRelays,
}
// Encode the event ID as nevent (with relay info)
nevent, err := utils.EncodeEventAsNevent(event.ID, publishedRelays, event.PubKey)
if err == nil {
response.Nevent = nevent
}
// Encode the event ID as note (without relay info)
note, err := utils.EncodeEventAsNote(event.ID)
if err == nil {
response.Note = note
}
// Encode the pubkey as npub
npub, err := utils.EncodePubkey(event.PubKey)
if err == nil {
response.Npub = npub
}
return response, nil
}
// Helper function to convert nostr.Tags to [][]string
func convertTags(tags nostr.Tags) [][]string {
result := make([][]string, len(tags))
for i, tag := range tags {
// Create a new string slice for each tag
tagStrings := make([]string, len(tag))
for j, v := range tag {
tagStrings[j] = v
}
result[i] = tagStrings
}
return result
}

View File

@ -6,7 +6,8 @@ import (
"fmt" "fmt"
"sync" "sync"
"time" "time"
"git.sovbit.dev/Enki/nostr-poster/internal/models"
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/events"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"go.uber.org/zap" "go.uber.org/zap"
) )
@ -20,6 +21,19 @@ type Manager struct {
logger *zap.Logger logger *zap.Logger
} }
func convertTags(tags nostr.Tags) [][]string {
result := make([][]string, len(tags))
for i, tag := range tags {
// Create a new string slice for each tag
tagStrings := make([]string, len(tag))
for j, v := range tag {
tagStrings[j] = v
}
result[i] = tagStrings
}
return result
}
// NewManager creates a new relay manager // NewManager creates a new relay manager
func NewManager(logger *zap.Logger) *Manager { func NewManager(logger *zap.Logger) *Manager {
if logger == nil { if logger == nil {
@ -186,6 +200,36 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin
return successful, nil return successful, nil
} }
// PublishEventWithEncoding publishes an event and returns encoded identifiers
func (m *Manager) PublishEventWithEncoding(ctx context.Context, event *nostr.Event) (*models.EventResponse, error) {
// First publish the event using the existing method
publishedRelays, err := m.PublishEvent(ctx, event)
if err != nil {
return nil, err
}
// Now encode the event with NIP-19 identifiers
encodedEvent, err := events.EncodeEvent(event, publishedRelays)
if err != nil {
m.logger.Warn("Failed to encode event with NIP-19",
zap.String("event_id", event.ID),
zap.Error(err))
// Return a basic response if encoding fails
return &models.EventResponse{
ID: event.ID,
Pubkey: event.PubKey,
CreatedAt: int64(event.CreatedAt),
Kind: event.Kind,
Content: event.Content,
Tags: convertTags(event.Tags), // Use the helper function here
Relays: publishedRelays,
}, nil
}
return encodedEvent, nil
}
// SubscribeToEvents subscribes to events matching the given filters // SubscribeToEvents subscribes to events matching the given filters
func (m *Manager) SubscribeToEvents(ctx context.Context, filters []nostr.Filter) (<-chan *nostr.Event, error) { func (m *Manager) SubscribeToEvents(ctx context.Context, filters []nostr.Filter) (<-chan *nostr.Event, error) {
m.mu.RLock() m.mu.RLock()

View File

@ -40,18 +40,30 @@ type ContentPoster func(
hashtags []string, hashtags []string,
) error ) error
// ContentPosterWithEncoding defines a function to post content and return encoded event
type ContentPosterWithEncoding func(
pubkey string,
contentPath string,
contentType string,
mediaURL string,
mediaHash string,
caption string,
hashtags []string,
) (*models.EventResponse, error)
// Scheduler manages scheduled content posting // Scheduler manages scheduled content posting
type Scheduler struct { type Scheduler struct {
db *db.DB db *db.DB
cron *cron.Cron cron *cron.Cron
logger *zap.Logger logger *zap.Logger
contentDir string contentDir string
archiveDir string archiveDir string
nip94Uploader MediaUploader nip94Uploader MediaUploader
blossomUploader MediaUploader blossomUploader MediaUploader
postContent ContentPoster postContent ContentPoster
botJobs map[int64]cron.EntryID postContentEncoded ContentPosterWithEncoding // New field for NIP-19 support
mu sync.RWMutex botJobs map[int64]cron.EntryID
mu sync.RWMutex
} }
// NewScheduler creates a new content scheduler // NewScheduler creates a new content scheduler
@ -63,6 +75,7 @@ func NewScheduler(
nip94Uploader MediaUploader, nip94Uploader MediaUploader,
blossomUploader MediaUploader, blossomUploader MediaUploader,
postContent ContentPoster, postContent ContentPoster,
postContentEncoded ContentPosterWithEncoding, // New parameter for NIP-19 support
) *Scheduler { ) *Scheduler {
if logger == nil { if logger == nil {
// Create a default logger // Create a default logger
@ -77,15 +90,16 @@ func NewScheduler(
cronScheduler := cron.New(cron.WithSeconds()) cronScheduler := cron.New(cron.WithSeconds())
return &Scheduler{ return &Scheduler{
db: db, db: db,
cron: cronScheduler, cron: cronScheduler,
logger: logger, logger: logger,
contentDir: contentDir, contentDir: contentDir,
archiveDir: archiveDir, archiveDir: archiveDir,
nip94Uploader: nip94Uploader, nip94Uploader: nip94Uploader,
blossomUploader: blossomUploader, blossomUploader: blossomUploader,
postContent: postContent, postContent: postContent,
botJobs: make(map[int64]cron.EntryID), postContentEncoded: postContentEncoded, // Initialize the new field
botJobs: make(map[int64]cron.EntryID),
} }
} }
@ -262,22 +276,65 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
filename := filepath.Base(contentPath) filename := filepath.Base(contentPath)
caption := strings.TrimSuffix(filename, filepath.Ext(filename)) caption := strings.TrimSuffix(filename, filepath.Ext(filename))
// Post the content // Post the content - use encoded version if available, otherwise use the original
err = s.postContent( var postErr error
bot.Pubkey,
contentPath,
contentType,
mediaURL,
mediaHash,
caption,
hashtags,
)
if err != nil { if s.postContentEncoded != nil {
s.logger.Error("Failed to post content", // Use the NIP-19 encoded version
zap.Int64("bot_id", bot.ID), encodedEvent, err := s.postContentEncoded(
zap.String("file", contentPath), bot.Pubkey,
zap.Error(err)) contentPath,
contentType,
mediaURL,
mediaHash,
caption,
hashtags,
)
if err != nil {
s.logger.Error("Failed to post content with encoding",
zap.Int64("bot_id", bot.ID),
zap.String("file", contentPath),
zap.Error(err))
postErr = err
} else {
// Success with encoded version
s.logger.Info("Successfully posted content with NIP-19 encoding",
zap.Int64("bot_id", bot.ID),
zap.String("file", contentPath),
zap.String("media_url", mediaURL),
zap.String("event_id", encodedEvent.ID),
zap.String("note", encodedEvent.Note),
zap.String("nevent", encodedEvent.Nevent))
postErr = nil
}
} else {
// Fall back to original function
postErr = s.postContent(
bot.Pubkey,
contentPath,
contentType,
mediaURL,
mediaHash,
caption,
hashtags,
)
if postErr != nil {
s.logger.Error("Failed to post content",
zap.Int64("bot_id", bot.ID),
zap.String("file", contentPath),
zap.Error(postErr))
} else {
s.logger.Info("Successfully posted content",
zap.Int64("bot_id", bot.ID),
zap.String("file", contentPath),
zap.String("media_url", mediaURL))
}
}
// If posting failed, return without archiving the file
if postErr != nil {
return return
} }
@ -291,11 +348,6 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
zap.Error(err)) zap.Error(err))
return return
} }
s.logger.Info("Successfully posted content",
zap.Int64("bot_id", bot.ID),
zap.String("file", contentPath),
zap.String("media_url", mediaURL))
} }
// Schedule the job // Schedule the job
@ -349,6 +401,7 @@ func (s *Scheduler) GetBlossomUploader() MediaUploader {
func (s *Scheduler) GetContentDir() string { func (s *Scheduler) GetContentDir() string {
return s.contentDir return s.contentDir
} }
// RunNow triggers an immediate post for a bot // RunNow triggers an immediate post for a bot
func (s *Scheduler) RunNow(botID int64) error { func (s *Scheduler) RunNow(botID int64) error {
// Load the bot with its configurations // Load the bot with its configurations
@ -395,4 +448,4 @@ func (s *Scheduler) RunNow(botID int64) error {
entry.Job.Run() entry.Job.Run()
return nil return nil
} }

30
internal/utils/nip-19.go Normal file
View File

@ -0,0 +1,30 @@
// internal/utils/nip19.go
package utils
import (
"github.com/nbd-wtf/go-nostr/nip19"
)
// EncodeEventAsNevent encodes an event ID and relays into a NIP-19 "nevent" identifier
func EncodeEventAsNevent(eventID string, relays []string, authorPubkey string) (string, error) {
// Use the direct function signature that your version of nip19 expects
return nip19.EncodeEvent(eventID, relays, authorPubkey)
}
// EncodeEventAsNote encodes an event ID into a NIP-19 "note" identifier (simpler)
func EncodeEventAsNote(eventID string) (string, error) {
note, err := nip19.EncodeNote(eventID)
if err != nil {
return "", err
}
return note, nil
}
// EncodePubkey encodes a public key into a NIP-19 "npub" identifier
func EncodePubkey(pubkey string) (string, error) {
npub, err := nip19.EncodePublicKey(pubkey)
if err != nil {
return "", err
}
return npub, nil
}

View File

@ -1,16 +1,19 @@
/* ============================================= /* =============================================
VARIABLES VARIABLES
============================================= */ ============================================= */
:root { :root {
/* Color Palette */ /* Color Palette */
--primary-black: #121212; --primary-black: #121212;
--secondary-black: #1e1e1e; --secondary-black: #1e1e1e;
--primary-gray: #2d2d2d; --primary-gray: #2d2d2d;
--secondary-gray: #3d3d3d; --secondary-gray: #3d3d3d;
--light-gray: #aaaaaa; --light-gray: #aaaaaa;
--primary-purple: #9370DB; /* Medium Purple */ --primary-purple: #9370DB;
--secondary-purple: #7B68EE; /* Medium Slate Blue */ /* Medium Purple */
--dark-purple: #6A5ACD; /* Slate Blue */ --secondary-purple: #7B68EE;
/* Medium Slate Blue */
--dark-purple: #6A5ACD;
/* Slate Blue */
--text-color: #e0e0e0; --text-color: #e0e0e0;
--success-color: #4CAF50; --success-color: #4CAF50;
--border-color: #444; --border-color: #444;
@ -93,7 +96,7 @@ body {
border-color: var(--primary-purple) !important; border-color: var(--primary-purple) !important;
} }
.btn-primary:hover, .btn-primary:hover,
.btn-primary:focus { .btn-primary:focus {
background-color: var(--secondary-purple) !important; background-color: var(--secondary-purple) !important;
border-color: var(--secondary-purple) !important; border-color: var(--secondary-purple) !important;
@ -112,14 +115,14 @@ body {
/* ============================================= /* =============================================
FORMS FORMS
============================================= */ ============================================= */
.form-control, .form-control,
.form-select { .form-select {
background-color: var(--primary-black); background-color: var(--primary-black);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-color); color: var(--text-color);
} }
.form-control:focus, .form-control:focus,
.form-select:focus { .form-select:focus {
background-color: var(--primary-black); background-color: var(--primary-black);
border-color: var(--primary-purple); border-color: var(--primary-purple);
@ -224,7 +227,7 @@ body {
margin-right: auto; margin-right: auto;
} }
#uploadPreviewContainer, #uploadPreviewContainer,
#mediaPreviewContainer { #mediaPreviewContainer {
max-width: 100%; max-width: 100%;
max-height: 250px; max-height: 250px;
@ -256,7 +259,7 @@ body {
} }
/* Server URL input improvements */ /* Server URL input improvements */
#primaryServerURL, #primaryServerURL,
#fallbackServerURL { #fallbackServerURL {
margin-top: 5px; margin-top: 5px;
font-size: 0.9rem; font-size: 0.9rem;
@ -275,6 +278,38 @@ body {
background-color: var(--primary-gray); background-color: var(--primary-gray);
} }
/* Event info display */
.event-info {
background-color: var(--primary-gray);
border-left: 3px solid var(--success-color);
margin-top: 15px;
}
.event-info .card-header {
background-color: rgba(76, 175, 80, 0.1);
}
.event-info .input-group {
background-color: var(--secondary-black);
}
.event-info input.form-control {
font-family: monospace;
font-size: 0.85rem;
background-color: var(--secondary-black);
border-color: var(--border-color);
}
.copy-btn:hover {
background-color: var(--primary-purple);
border-color: var(--primary-purple);
}
.copy-btn:active {
background-color: var(--dark-purple);
}
/* ============================================= /* =============================================
RESPONSIVE STYLES RESPONSIVE STYLES
============================================= */ ============================================= */
@ -282,12 +317,12 @@ body {
.card { .card {
margin-bottom: 20px; margin-bottom: 20px;
} }
.bot-selection-container { .bot-selection-container {
padding: 15px; padding: 15px;
margin-bottom: 20px; margin-bottom: 20px;
} }
.upload-preview { .upload-preview {
max-height: 150px; max-height: 150px;
} }

View File

@ -105,18 +105,18 @@ document.addEventListener('DOMContentLoaded', () => {
postKindRadios.forEach(radio => { postKindRadios.forEach(radio => {
radio.addEventListener('change', () => { radio.addEventListener('change', () => {
titleField.classList.toggle('d-none', radio.value !== '20'); titleField.classList.toggle('d-none', radio.value !== '20');
// Show/hide required media elements for kind 20 // Show/hide required media elements for kind 20
const isKind20 = radio.value === '20'; const isKind20 = radio.value === '20';
const kind20MediaRequired = document.getElementById('kind20MediaRequired'); const kind20MediaRequired = document.getElementById('kind20MediaRequired');
if (kind20MediaRequired) { if (kind20MediaRequired) {
kind20MediaRequired.style.display = isKind20 ? 'inline-block' : 'none'; kind20MediaRequired.style.display = isKind20 ? 'inline-block' : 'none';
} }
// For kind 20, validate that media is provided before posting // For kind 20, validate that media is provided before posting
if (submitPostBtn) { if (submitPostBtn) {
submitPostBtn.title = isKind20 ? submitPostBtn.title = isKind20 ?
'Picture posts require a title and an image URL' : 'Picture posts require a title and an image URL' :
'Create a standard text post'; 'Create a standard text post';
} }
}); });
@ -177,7 +177,35 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
createManualPost(currentBotId); createManualPost(currentBotId)
.then(data => {
alert('Post created successfully!');
console.log('Post success response:', data);
// Display event information if present
if (data.event) {
displayEventInfo(data.event);
}
// Reset form
manualPostContent.value = '';
postHashtags.value = '';
postTitle.value = '';
postMediaInput.value = '';
if (mediaUrlInput) mediaUrlInput.value = '';
if (mediaAltText) mediaAltText.value = '';
mediaPreviewContainer.classList.add('d-none');
// Reset button
submitPostBtn.disabled = false;
submitPostBtn.textContent = 'Post Now';
})
.catch(error => {
console.error('Error creating post:', error);
alert('Error creating post: ' + error.message);
submitPostBtn.disabled = false;
submitPostBtn.textContent = 'Post Now';
});
} }
// Handle saving media server settings // Handle saving media server settings
@ -224,13 +252,13 @@ document.addEventListener('DOMContentLoaded', () => {
function validatePostData() { function validatePostData() {
const kind = parseInt(document.querySelector('input[name="postKind"]:checked').value); const kind = parseInt(document.querySelector('input[name="postKind"]:checked').value);
const content = manualPostContent.value.trim(); const content = manualPostContent.value.trim();
// Basic validation for all post types // Basic validation for all post types
if (!content) { if (!content) {
alert('Post content is required'); alert('Post content is required');
return false; return false;
} }
// Additional validation for kind 20 posts // Additional validation for kind 20 posts
if (kind === 20) { if (kind === 20) {
const title = postTitle.value.trim(); const title = postTitle.value.trim();
@ -238,21 +266,67 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Title is required for Picture Posts (kind: 20)'); alert('Title is required for Picture Posts (kind: 20)');
return false; return false;
} }
// Check if we have a media URL either in the content or in the mediaUrlInput // Check if we have a media URL either in the content or in the mediaUrlInput
const mediaUrl = mediaUrlInput.value.trim(); const mediaUrl = mediaUrlInput.value.trim();
const urlRegex = /(https?:\/\/[^\s]+)/g; const urlRegex = /(https?:\/\/[^\s]+)/g;
const contentContainsUrl = urlRegex.test(content); const contentContainsUrl = urlRegex.test(content);
if (!mediaUrl && !contentContainsUrl) { if (!mediaUrl && !contentContainsUrl) {
alert('Picture posts require an image URL. Please upload an image or enter a URL in the Media URL field or in the content.'); alert('Picture posts require an image URL. Please upload an image or enter a URL in the Media URL field or in the content.');
return false; return false;
} }
} }
return true; return true;
} }
// Add this function to content.js
function displayEventInfo(event) {
// Get the dedicated container
const container = document.getElementById('eventInfoContainer');
if (!container) return;
// Create HTML for the event info
const html = `
<div class="event-info card mb-3">
<div class="card-header">
<h5>Post Published Successfully</h5>
</div>
<div class="card-body">
<div class="mb-2">
<label class="form-label">Note ID (NIP-19):</label>
<div class="input-group">
<input type="text" class="form-control" value="${event.note || ''}" readonly>
<button class="btn btn-outline-secondary copy-btn" data-value="${event.note || ''}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
</button>
</div>
</div>
<div class="mb-2">
<label class="form-label">Event with Relays (NIP-19):</label>
<div class="input-group">
<input type="text" class="form-control" value="${event.nevent || ''}" readonly>
<button class="btn btn-outline-secondary copy-btn" data-value="${event.nevent || ''}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
`;
// Update the container and make it visible
container.innerHTML = html;
container.classList.remove('d-none');
}
// =================================================== // ===================================================
// API Functions // API Functions
// =================================================== // ===================================================
@ -296,17 +370,18 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// Render the list of content files // Updated renderContentFiles function
function renderContentFiles(botId, files) { function renderContentFiles(botId, files) {
const contentArea = document.getElementById('contentArea'); const contentArea = document.getElementById('contentArea');
if (!contentArea) return; if (!contentArea) return;
if (!files || files.length === 0) { // Generate ONLY the file list, no upload form
contentArea.innerHTML = '<p>No files found. Upload some content!</p>'; let html = '';
return;
} if (!files || files.length === 0) {
html = '<p>No files found. Upload some content!</p>';
let html = '<ul class="list-group">'; } else {
html = '<ul class="list-group">';
for (const file of files) { for (const file of files) {
html += ` html += `
<li class="list-group-item d-flex justify-content-between align-items-center"> <li class="list-group-item d-flex justify-content-between align-items-center">
@ -316,9 +391,11 @@ document.addEventListener('DOMContentLoaded', () => {
`; `;
} }
html += '</ul>'; html += '</ul>';
contentArea.innerHTML = html;
} }
// Set the content area HTML to just the files list
contentArea.innerHTML = html;
}
// Upload media for the manual post // Upload media for the manual post
function quickUploadMedia(botId, file) { function quickUploadMedia(botId, file) {
@ -371,7 +448,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Insert the media URL into the post content // Insert the media URL into the post content
const textArea = document.getElementById('manualPostContent'); const textArea = document.getElementById('manualPostContent');
let mediaUrl = data.url; let mediaUrl = data.url;
// Also update the media URL input field // Also update the media URL input field
const mediaUrlInput = document.getElementById('mediaUrlInput'); const mediaUrlInput = document.getElementById('mediaUrlInput');
if (mediaUrlInput) { if (mediaUrlInput) {
@ -402,7 +479,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!validatePostData()) { if (!validatePostData()) {
return; return;
} }
const content = manualPostContent.value.trim(); const content = manualPostContent.value.trim();
const hashtagsValue = postHashtags.value.trim(); const hashtagsValue = postHashtags.value.trim();
const hashtags = hashtagsValue const hashtags = hashtagsValue
@ -419,7 +496,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Extract media URLs and alt text // Extract media URLs and alt text
const mediaUrl = mediaUrlInput ? mediaUrlInput.value.trim() : ''; const mediaUrl = mediaUrlInput ? mediaUrlInput.value.trim() : '';
const altText = mediaAltText ? mediaAltText.value.trim() : ''; const altText = mediaAltText ? mediaAltText.value.trim() : '';
// Build the post data based on kind // Build the post data based on kind
const postData = { const postData = {
kind: kind, kind: kind,
@ -429,13 +506,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (kind === 20) { if (kind === 20) {
postData.title = title; postData.title = title;
// For kind 20, we need to ensure we have a valid URL // For kind 20, we need to ensure we have a valid URL
// If we have a specific media URL field value, add it to the content if not already there // If we have a specific media URL field value, add it to the content if not already there
if (mediaUrl && !content.includes(mediaUrl)) { if (mediaUrl && !content.includes(mediaUrl)) {
postData.content = content + '\n\n' + mediaUrl; postData.content = content + '\n\n' + mediaUrl;
} }
// Add alt text if provided // Add alt text if provided
if (altText) { if (altText) {
postData.alt = altText; postData.alt = altText;
@ -510,4 +587,23 @@ document.addEventListener('DOMContentLoaded', () => {
console.error('Error parsing saved media config:', e); console.error('Error parsing saved media config:', e);
} }
} }
});
document.addEventListener('click', function(e) {
if (e.target.closest('.copy-btn')) {
const btn = e.target.closest('.copy-btn');
const valueToCopy = btn.getAttribute('data-value');
navigator.clipboard.writeText(valueToCopy)
.then(() => {
const originalHTML = btn.innerHTML;
btn.innerHTML = 'Copied!';
setTimeout(() => {
btn.innerHTML = originalHTML;
}, 2000);
})
.catch(err => {
console.error('Failed to copy: ', err);
});
}
}); });

View File

@ -476,6 +476,93 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
window.publishBotProfile = async function(botId) {
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${API_ENDPOINT}/api/bots/${botId}/profile/publish`, {
method: 'POST',
headers: {
'Authorization': token
}
});
if (!response.ok) {
const errData = await response.json().catch(() => ({}));
throw new Error(errData.error || 'Failed to publish profile');
}
const data = await response.json();
// Check if event data with NIP-19 encoding is available
if (data.event) {
// Create a modal to show the encoded event info
const modalId = 'eventInfoModal';
// Remove any existing modal with the same ID
const existingModal = document.getElementById(modalId);
if (existingModal) {
existingModal.remove();
}
const eventModal = document.createElement('div');
eventModal.className = 'modal fade';
eventModal.id = modalId;
eventModal.setAttribute('tabindex', '-1');
eventModal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Profile Published Successfully</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<label class="form-label">Note ID (NIP-19):</label>
<div class="input-group">
<input type="text" class="form-control" value="${data.event.note || ''}" readonly>
<button class="btn btn-outline-secondary copy-btn" data-value="${data.event.note || ''}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
</button>
</div>
</div>
<div class="mb-2">
<label class="form-label">Event with Relays (NIP-19):</label>
<div class="input-group">
<input type="text" class="form-control" value="${data.event.nevent || ''}" readonly>
<button class="btn btn-outline-secondary copy-btn" data-value="${data.event.nevent || ''}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-clipboard" viewBox="0 0 16 16">
<path d="M4 1.5H3a2 2 0 0 0-2 2V14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V3.5a2 2 0 0 0-2-2h-1v1h1a1 1 0 0 1 1 1V14a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3.5a1 1 0 0 1 1-1h1v-1z"/>
<path d="M9.5 1a.5.5 0 0 1 .5.5v1a.5.5 0 0 1-.5.5h-3a.5.5 0 0 1-.5-.5v-1a.5.5 0 0 1 .5-.5h3zm-3-1A1.5 1.5 0 0 0 5 1.5v1A1.5 1.5 0 0 0 6.5 4h3A1.5 1.5 0 0 0 11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3z"/>
</svg>
</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
`;
// Add the modal to the document
document.body.appendChild(eventModal);
// Show the modal using Bootstrap
const bsModal = new bootstrap.Modal(document.getElementById(modalId));
bsModal.show();
}
alert('Profile published successfully!');
} catch (err) {
console.error('Error publishing profile:', err);
alert(`Error publishing profile: ${err.message}`);
}
};
/* ---------------------------------------------------- /* ----------------------------------------------------
* Show Create Bot Modal * Show Create Bot Modal

View File

@ -131,13 +131,15 @@
<h4>Content Files</h4> <h4>Content Files</h4>
</div> </div>
<div class="card-body"> <div class="card-body">
<div id="contentArea"> <!-- File list area - with scrollable container -->
<div id="contentArea" style="max-height: 300px; overflow-y: auto;">
<!-- Files will be listed here --> <!-- Files will be listed here -->
<p>Select a bot and click "Load Content" to see files.</p> <p>Select a bot and click "Load Content" to see files.</p>
</div> </div>
<hr> <hr>
<!-- Upload section - using existing structure without an ID -->
<h5>Upload New Content</h5> <h5>Upload New Content</h5>
<div class="mb-3"> <div class="mb-3">
<input type="file" id="uploadFileInput" class="form-control" accept="image/*,video/*"> <input type="file" id="uploadFileInput" class="form-control" accept="image/*,video/*">
@ -150,57 +152,56 @@
</div> </div>
</div> </div>
<!-- Manual Post Section (Right) --> <!-- Manual Post Section (Right) - FIXED: added col-md-6 wrapper -->
<div class="card mb-4"> <div class="col-md-6 mb-4">
<div class="card-header"> <div class="card">
<h4>Create Manual Post</h4> <div class="card-header">
</div> <h4>Create Manual Post</h4>
<div class="card-body"> </div>
<!-- Post Type Selection --> <div class="card-body">
<div class="mb-3"> <!-- Post Type Selection -->
<label class="form-label">Post Type</label> <div class="mb-3">
<div class="form-check"> <label class="form-label">Post Type</label>
<input class="form-check-input" type="radio" name="postKind" id="postKind1" value="1" checked> <div class="form-check">
<label class="form-check-label" for="postKind1"> <input class="form-check-input" type="radio" name="postKind" id="postKind1" value="1" checked>
Standard Post (kind: 1) <label class="form-check-label" for="postKind1">
</label> Standard Post (kind: 1)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="postKind" id="postKind20" value="20">
<label class="form-check-label" for="postKind20">
<strong>Picture Post (kind: 20)</strong> - Images displayed in a gallery-like feed
</label>
</div>
</div> </div>
<div class="form-check">
<input class="form-check-input" type="radio" name="postKind" id="postKind20" value="20"> <!-- Title Field (only for kind 20) -->
<label class="form-check-label" for="postKind20"> <div class="mb-3 d-none" id="titleField">
<strong>Picture Post (kind: 20)</strong> - Images displayed in a gallery-like feed <label for="postTitle" class="form-label">Title <span class="text-danger">*</span></label>
</label> <input type="text" class="form-control" id="postTitle" placeholder="Post title (required for kind: 20)">
<small class="form-text text-muted">Required for picture posts (kind: 20)</small>
</div> </div>
</div>
<!-- Title Field (only for kind 20) --> <!-- Content Field -->
<div class="mb-3 d-none" id="titleField"> <div class="mb-3">
<label for="postTitle" class="form-label">Title <span class="text-danger">*</span></label> <label for="manualPostContent" class="form-label">Content</label>
<input type="text" class="form-control" id="postTitle" placeholder="Post title (required for kind: 20)"> <textarea class="form-control" id="manualPostContent" rows="4"
<small class="form-text text-muted">Required for picture posts (kind: 20)</small> placeholder="Write your post content here..."></textarea>
</div> </div>
<!-- Content Field --> <!-- Hashtags Field -->
<div class="mb-3"> <div class="mb-3">
<label for="manualPostContent" class="form-label">Content</label> <label for="postHashtags" class="form-label">Hashtags</label>
<textarea class="form-control" id="manualPostContent" rows="4" <input type="text" class="form-control" id="postHashtags"
placeholder="Write your post content here..."></textarea> placeholder="comma separated: art, photography, etc">
</div> </div>
<!-- Hashtags Field --> <!-- Media Section - Enhanced for Kind 20 -->
<div class="mb-3"> <div class="mb-3">
<label for="postHashtags" class="form-label">Hashtags</label> <h5 class="mb-2">Media Attachment</h5>
<input type="text" class="form-control" id="postHashtags"
placeholder="comma separated: art, photography, etc">
</div>
<!-- Media Section - Enhanced for Kind 20 -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Media Attachment</h5>
<span class="badge bg-info" id="kind20MediaRequired" style="display: none;">Required for kind: 20</span> <span class="badge bg-info" id="kind20MediaRequired" style="display: none;">Required for kind: 20</span>
</div>
<div class="card-body">
<!-- Upload Section --> <!-- Upload Section -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Upload Image</label> <label class="form-label">Upload Image</label>
@ -216,8 +217,7 @@
<label for="mediaUrlInput" class="form-label">Media URL</label> <label for="mediaUrlInput" class="form-label">Media URL</label>
<input type="text" class="form-control" id="mediaUrlInput" <input type="text" class="form-control" id="mediaUrlInput"
placeholder="Enter media URL or upload an image"> placeholder="Enter media URL or upload an image">
<small class="form-text text-muted">For kind: 20 posts, an image URL is required (either here or in <small class="form-text text-muted">For kind: 20 posts, an image URL is required</small>
the content)</small>
</div> </div>
<!-- Alt Text Field (for accessibility) --> <!-- Alt Text Field (for accessibility) -->
@ -225,28 +225,25 @@
<label for="mediaAltText" class="form-label">Alt Text</label> <label for="mediaAltText" class="form-label">Alt Text</label>
<input type="text" class="form-control" id="mediaAltText" <input type="text" class="form-control" id="mediaAltText"
placeholder="Describe the image for accessibility"> placeholder="Describe the image for accessibility">
<small class="form-text text-muted">Recommended for image description (improves accessibility)</small> <small class="form-text text-muted">Recommended for image description</small>
</div> </div>
<!-- Media Preview --> <!-- Media Preview -->
<div id="mediaPreviewContainer" class="d-none mt-2"> <div id="mediaPreviewContainer" class="d-none mt-2">
<div class="card"> <img id="mediaPreview" class="upload-preview img-fluid mb-2">
<div class="card-body text-center"> <div id="mediaLinkContainer" class="media-link">
<img id="mediaPreview" class="upload-preview img-fluid mb-2"> <small class="text-muted">Media will appear here after upload</small>
<div id="mediaLinkContainer" class="media-link">
<small class="text-muted">Media will appear here after upload</small>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<button type="button" class="btn btn-success mt-2" id="submitPostBtn">Post Now</button> <button type="button" class="btn btn-success mt-2" id="submitPostBtn">Post Now</button>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div id="eventInfoContainer" class="mt-3"></div>
</div> </div>
</body> </body>