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
import (
"context"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/nbd-wtf/go-nostr"
"git.sovbit.dev/Enki/nostr-poster/internal/api"
"git.sovbit.dev/Enki/nostr-poster/internal/auth"
"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/upload/blossom"
"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/poster"
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/relay"
@ -146,15 +151,120 @@ func main() {
logger,
)
// Create post content function
// Create standard post content function
postContentFunc := poster.CreatePostContentFunc(
eventManager,
relayManager,
mediaPrep,
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(
database,
logger,
@ -163,6 +273,7 @@ func main() {
nip94Uploader,
blossomUploader,
postContentFunc,
postContentEncodedFunc, // New encoded function
)
// 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
}
// 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
func (s *BotService) loadBotRelatedData(bot *models.Bot) error {
// 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) {
pubkey := c.GetString("pubkey")
botIDStr := c.Param("id")
@ -883,14 +883,8 @@ func (a *API) createManualPost(c *gin.Context) {
if len(matches) > 0 {
mediaURL = matches[0] // Use the first URL found
// Try to determine media type - this is a placeholder
// In a real implementation, you might want to make an HTTP head request
// or infer from URL extension
// Try to determine media type
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
@ -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)
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 {
a.logger.Error("Failed to publish event", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish post: " + err.Error()})
return
}
// Return success
// Return the encoded event response
c.JSON(http.StatusOK, gin.H{
"message": "Post published successfully",
"event_id": event.ID,
"relays": publishedRelays,
"message": "Post published successfully",
"event": encodedEvent,
})
}

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"
"sync"
"time"
"git.sovbit.dev/Enki/nostr-poster/internal/models"
"git.sovbit.dev/Enki/nostr-poster/internal/nostr/events"
"github.com/nbd-wtf/go-nostr"
"go.uber.org/zap"
)
@ -20,6 +21,19 @@ type Manager struct {
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
func NewManager(logger *zap.Logger) *Manager {
if logger == nil {
@ -186,6 +200,36 @@ func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]strin
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
func (m *Manager) SubscribeToEvents(ctx context.Context, filters []nostr.Filter) (<-chan *nostr.Event, error) {
m.mu.RLock()

View File

@ -40,18 +40,30 @@ type ContentPoster func(
hashtags []string,
) 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
type Scheduler struct {
db *db.DB
cron *cron.Cron
logger *zap.Logger
contentDir string
archiveDir string
nip94Uploader MediaUploader
blossomUploader MediaUploader
postContent ContentPoster
botJobs map[int64]cron.EntryID
mu sync.RWMutex
db *db.DB
cron *cron.Cron
logger *zap.Logger
contentDir string
archiveDir string
nip94Uploader MediaUploader
blossomUploader MediaUploader
postContent ContentPoster
postContentEncoded ContentPosterWithEncoding // New field for NIP-19 support
botJobs map[int64]cron.EntryID
mu sync.RWMutex
}
// NewScheduler creates a new content scheduler
@ -63,6 +75,7 @@ func NewScheduler(
nip94Uploader MediaUploader,
blossomUploader MediaUploader,
postContent ContentPoster,
postContentEncoded ContentPosterWithEncoding, // New parameter for NIP-19 support
) *Scheduler {
if logger == nil {
// Create a default logger
@ -77,15 +90,16 @@ func NewScheduler(
cronScheduler := cron.New(cron.WithSeconds())
return &Scheduler{
db: db,
cron: cronScheduler,
logger: logger,
contentDir: contentDir,
archiveDir: archiveDir,
nip94Uploader: nip94Uploader,
blossomUploader: blossomUploader,
postContent: postContent,
botJobs: make(map[int64]cron.EntryID),
db: db,
cron: cronScheduler,
logger: logger,
contentDir: contentDir,
archiveDir: archiveDir,
nip94Uploader: nip94Uploader,
blossomUploader: blossomUploader,
postContent: postContent,
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)
caption := strings.TrimSuffix(filename, filepath.Ext(filename))
// Post the content
err = s.postContent(
bot.Pubkey,
contentPath,
contentType,
mediaURL,
mediaHash,
caption,
hashtags,
)
// Post the content - use encoded version if available, otherwise use the original
var postErr error
if err != nil {
s.logger.Error("Failed to post content",
zap.Int64("bot_id", bot.ID),
zap.String("file", contentPath),
zap.Error(err))
if s.postContentEncoded != nil {
// Use the NIP-19 encoded version
encodedEvent, err := s.postContentEncoded(
bot.Pubkey,
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
}
@ -291,11 +348,6 @@ func (s *Scheduler) ScheduleBot(bot *models.Bot) error {
zap.Error(err))
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
@ -349,6 +401,7 @@ func (s *Scheduler) GetBlossomUploader() MediaUploader {
func (s *Scheduler) GetContentDir() string {
return s.contentDir
}
// RunNow triggers an immediate post for a bot
func (s *Scheduler) RunNow(botID int64) error {
// Load the bot with its configurations
@ -395,4 +448,4 @@ func (s *Scheduler) RunNow(botID int64) error {
entry.Job.Run()
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
============================================= */
:root {
:root {
/* Color Palette */
--primary-black: #121212;
--secondary-black: #1e1e1e;
--primary-gray: #2d2d2d;
--secondary-gray: #3d3d3d;
--light-gray: #aaaaaa;
--primary-purple: #9370DB; /* Medium Purple */
--secondary-purple: #7B68EE; /* Medium Slate Blue */
--dark-purple: #6A5ACD; /* Slate Blue */
--primary-purple: #9370DB;
/* Medium Purple */
--secondary-purple: #7B68EE;
/* Medium Slate Blue */
--dark-purple: #6A5ACD;
/* Slate Blue */
--text-color: #e0e0e0;
--success-color: #4CAF50;
--border-color: #444;
@ -93,7 +96,7 @@ body {
border-color: var(--primary-purple) !important;
}
.btn-primary:hover,
.btn-primary:hover,
.btn-primary:focus {
background-color: var(--secondary-purple) !important;
border-color: var(--secondary-purple) !important;
@ -112,14 +115,14 @@ body {
/* =============================================
FORMS
============================================= */
.form-control,
.form-control,
.form-select {
background-color: var(--primary-black);
border: 1px solid var(--border-color);
color: var(--text-color);
}
.form-control:focus,
.form-control:focus,
.form-select:focus {
background-color: var(--primary-black);
border-color: var(--primary-purple);
@ -224,7 +227,7 @@ body {
margin-right: auto;
}
#uploadPreviewContainer,
#uploadPreviewContainer,
#mediaPreviewContainer {
max-width: 100%;
max-height: 250px;
@ -256,7 +259,7 @@ body {
}
/* Server URL input improvements */
#primaryServerURL,
#primaryServerURL,
#fallbackServerURL {
margin-top: 5px;
font-size: 0.9rem;
@ -275,6 +278,38 @@ body {
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
============================================= */
@ -282,12 +317,12 @@ body {
.card {
margin-bottom: 20px;
}
.bot-selection-container {
padding: 15px;
margin-bottom: 20px;
}
.upload-preview {
max-height: 150px;
}

View File

@ -105,18 +105,18 @@ document.addEventListener('DOMContentLoaded', () => {
postKindRadios.forEach(radio => {
radio.addEventListener('change', () => {
titleField.classList.toggle('d-none', radio.value !== '20');
// Show/hide required media elements for kind 20
const isKind20 = radio.value === '20';
const kind20MediaRequired = document.getElementById('kind20MediaRequired');
if (kind20MediaRequired) {
kind20MediaRequired.style.display = isKind20 ? 'inline-block' : 'none';
}
// For kind 20, validate that media is provided before posting
if (submitPostBtn) {
submitPostBtn.title = isKind20 ?
'Picture posts require a title and an image URL' :
submitPostBtn.title = isKind20 ?
'Picture posts require a title and an image URL' :
'Create a standard text post';
}
});
@ -177,7 +177,35 @@ document.addEventListener('DOMContentLoaded', () => {
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
@ -224,13 +252,13 @@ document.addEventListener('DOMContentLoaded', () => {
function validatePostData() {
const kind = parseInt(document.querySelector('input[name="postKind"]:checked').value);
const content = manualPostContent.value.trim();
// Basic validation for all post types
if (!content) {
alert('Post content is required');
return false;
}
// Additional validation for kind 20 posts
if (kind === 20) {
const title = postTitle.value.trim();
@ -238,21 +266,67 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Title is required for Picture Posts (kind: 20)');
return false;
}
// Check if we have a media URL either in the content or in the mediaUrlInput
const mediaUrl = mediaUrlInput.value.trim();
const urlRegex = /(https?:\/\/[^\s]+)/g;
const contentContainsUrl = urlRegex.test(content);
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.');
return false;
}
}
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
// ===================================================
@ -296,17 +370,18 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Render the list of content files
function renderContentFiles(botId, files) {
const contentArea = document.getElementById('contentArea');
if (!contentArea) return;
// Updated renderContentFiles function
function renderContentFiles(botId, files) {
const contentArea = document.getElementById('contentArea');
if (!contentArea) return;
if (!files || files.length === 0) {
contentArea.innerHTML = '<p>No files found. Upload some content!</p>';
return;
}
let html = '<ul class="list-group">';
// Generate ONLY the file list, no upload form
let html = '';
if (!files || files.length === 0) {
html = '<p>No files found. Upload some content!</p>';
} else {
html = '<ul class="list-group">';
for (const file of files) {
html += `
<li class="list-group-item d-flex justify-content-between align-items-center">
@ -316,9 +391,11 @@ document.addEventListener('DOMContentLoaded', () => {
`;
}
html += '</ul>';
contentArea.innerHTML = html;
}
// Set the content area HTML to just the files list
contentArea.innerHTML = html;
}
// Upload media for the manual post
function quickUploadMedia(botId, file) {
@ -371,7 +448,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Insert the media URL into the post content
const textArea = document.getElementById('manualPostContent');
let mediaUrl = data.url;
// Also update the media URL input field
const mediaUrlInput = document.getElementById('mediaUrlInput');
if (mediaUrlInput) {
@ -402,7 +479,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (!validatePostData()) {
return;
}
const content = manualPostContent.value.trim();
const hashtagsValue = postHashtags.value.trim();
const hashtags = hashtagsValue
@ -419,7 +496,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Extract media URLs and alt text
const mediaUrl = mediaUrlInput ? mediaUrlInput.value.trim() : '';
const altText = mediaAltText ? mediaAltText.value.trim() : '';
// Build the post data based on kind
const postData = {
kind: kind,
@ -429,13 +506,13 @@ document.addEventListener('DOMContentLoaded', () => {
if (kind === 20) {
postData.title = title;
// 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 (mediaUrl && !content.includes(mediaUrl)) {
postData.content = content + '\n\n' + mediaUrl;
}
// Add alt text if provided
if (altText) {
postData.alt = altText;
@ -510,4 +587,23 @@ document.addEventListener('DOMContentLoaded', () => {
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

View File

@ -131,13 +131,15 @@
<h4>Content Files</h4>
</div>
<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 -->
<p>Select a bot and click "Load Content" to see files.</p>
</div>
<hr>
<!-- Upload section - using existing structure without an ID -->
<h5>Upload New Content</h5>
<div class="mb-3">
<input type="file" id="uploadFileInput" class="form-control" accept="image/*,video/*">
@ -150,57 +152,56 @@
</div>
</div>
<!-- Manual Post Section (Right) -->
<div class="card mb-4">
<div class="card-header">
<h4>Create Manual Post</h4>
</div>
<div class="card-body">
<!-- Post Type Selection -->
<div class="mb-3">
<label class="form-label">Post Type</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="postKind" id="postKind1" value="1" checked>
<label class="form-check-label" for="postKind1">
Standard Post (kind: 1)
</label>
<!-- Manual Post Section (Right) - FIXED: added col-md-6 wrapper -->
<div class="col-md-6 mb-4">
<div class="card">
<div class="card-header">
<h4>Create Manual Post</h4>
</div>
<div class="card-body">
<!-- Post Type Selection -->
<div class="mb-3">
<label class="form-label">Post Type</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="postKind" id="postKind1" value="1" checked>
<label class="form-check-label" for="postKind1">
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 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>
<!-- Title Field (only for kind 20) -->
<div class="mb-3 d-none" id="titleField">
<label for="postTitle" class="form-label">Title <span class="text-danger">*</span></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>
<!-- Title Field (only for kind 20) -->
<div class="mb-3 d-none" id="titleField">
<label for="postTitle" class="form-label">Title <span class="text-danger">*</span></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>
<!-- Content Field -->
<div class="mb-3">
<label for="manualPostContent" class="form-label">Content</label>
<textarea class="form-control" id="manualPostContent" rows="4"
placeholder="Write your post content here..."></textarea>
</div>
<!-- Content Field -->
<div class="mb-3">
<label for="manualPostContent" class="form-label">Content</label>
<textarea class="form-control" id="manualPostContent" rows="4"
placeholder="Write your post content here..."></textarea>
</div>
<!-- Hashtags Field -->
<div class="mb-3">
<label for="postHashtags" class="form-label">Hashtags</label>
<input type="text" class="form-control" id="postHashtags"
placeholder="comma separated: art, photography, etc">
</div>
<!-- Hashtags Field -->
<div class="mb-3">
<label for="postHashtags" class="form-label">Hashtags</label>
<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>
<!-- Media Section - Enhanced for Kind 20 -->
<div class="mb-3">
<h5 class="mb-2">Media Attachment</h5>
<span class="badge bg-info" id="kind20MediaRequired" style="display: none;">Required for kind: 20</span>
</div>
<div class="card-body">
<!-- Upload Section -->
<div class="mb-3">
<label class="form-label">Upload Image</label>
@ -216,8 +217,7 @@
<label for="mediaUrlInput" class="form-label">Media URL</label>
<input type="text" class="form-control" id="mediaUrlInput"
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
the content)</small>
<small class="form-text text-muted">For kind: 20 posts, an image URL is required</small>
</div>
<!-- Alt Text Field (for accessibility) -->
@ -225,28 +225,25 @@
<label for="mediaAltText" class="form-label">Alt Text</label>
<input type="text" class="form-control" id="mediaAltText"
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>
<!-- Media Preview -->
<div id="mediaPreviewContainer" class="d-none mt-2">
<div class="card">
<div class="card-body text-center">
<img id="mediaPreview" class="upload-preview img-fluid mb-2">
<div id="mediaLinkContainer" class="media-link">
<small class="text-muted">Media will appear here after upload</small>
</div>
</div>
<img id="mediaPreview" class="upload-preview img-fluid mb-2">
<div id="mediaLinkContainer" class="media-link">
<small class="text-muted">Media will appear here after upload</small>
</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 id="eventInfoContainer" class="mt-3"></div>
</div>
</body>