21 Commits

Author SHA1 Message Date
106d3b1fa1 fix? 2025-10-05 21:25:44 +01:00
b9f6b77286 another fix 2025-10-05 21:13:53 +01:00
3c8fd843e6 debug: info 2025-10-05 21:07:24 +01:00
e61af3007f fix! 2025-10-05 20:55:26 +01:00
3594baceb5 lets pretend it is working 2025-10-05 20:47:28 +01:00
d534779fad fix: swift stuff 2025-10-05 20:35:49 +01:00
a776c88cab fix: more swift stuff 2025-10-05 20:34:23 +01:00
72de7c7648 fix: swift 2025-10-05 20:31:59 +01:00
a8b150857c fix 2025-10-05 19:56:48 +01:00
dd4f508346 fix 2025-10-05 19:05:17 +01:00
f21ee57632 fix: search 2025-10-05 16:27:38 +01:00
0e42c9002b fix: minor bugs 2025-10-05 16:25:00 +01:00
9e60a41f0a fix: processing images indicator 2025-10-05 15:59:14 +01:00
eaff553dc9 fix: image stacks on image page 2025-10-05 15:55:50 +01:00
6880811236 fix: delete schema column button 2025-10-05 15:54:46 +01:00
38bda46dcf hack: to get response format to work properly
I think this might be a bug with requesty
2025-10-05 15:35:09 +01:00
bd86ad499b fix: setting entities to done 2025-10-05 15:20:35 +01:00
838ab37fc1 refactor: list -> stack 2025-10-05 15:12:37 +01:00
9948d2521b feat: creating list processor 2025-10-05 15:08:25 +01:00
64abf79f9c feat: stack model processor 2025-10-05 14:54:24 +01:00
0d41a65435 feat: deleting column from frontend 2025-10-05 14:53:04 +01:00
32 changed files with 649 additions and 677 deletions

View File

@ -7,6 +7,7 @@ import (
"screenmark/screenmark/.gen/haystack/haystack/model" "screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client" "screenmark/screenmark/agents/client"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"strings"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/google/uuid" "github.com/google/uuid"
@ -21,6 +22,8 @@ and add a good description for each one.
You can add fields if you think they make a lot of sense. You can add fields if you think they make a lot of sense.
You can remove fields if they are not correct, but be sure before you do this. You can remove fields if they are not correct, but be sure before you do this.
You must respond in json format, do not add backticks to the json. ONLY valid json.
` `
const listJsonSchema = ` const listJsonSchema = `
@ -76,15 +79,15 @@ type createNewListArguments struct {
type CreateListAgent struct { type CreateListAgent struct {
client client.AgentClient client client.AgentClient
listModel models.StackModel stackModel models.StackModel
} }
func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, userReq string) error { func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, stackID uuid.UUID, title string, userReq string) error {
request := client.AgentRequestBody{ request := client.AgentRequestBody{
Model: "policy/images", Model: "policy/images",
Temperature: 0.3, Temperature: 0.3,
ResponseFormat: client.ResponseFormat{ ResponseFormat: client.ResponseFormat{
Type: "json_object", Type: "json_schema",
JsonSchema: listJsonSchema, JsonSchema: listJsonSchema,
}, },
Chat: &client.Chat{ Chat: &client.Chat{
@ -93,7 +96,10 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
} }
request.Chat.AddSystem(agent.client.Options.SystemPrompt) request.Chat.AddSystem(agent.client.Options.SystemPrompt)
request.Chat.AddUser(userReq)
req := fmt.Sprintf("List title: %s | Users list description: %s", title, userReq)
request.Chat.AddUser(req)
resp, err := agent.client.Request(&request) resp, err := agent.client.Request(&request)
if err != nil { if err != nil {
@ -102,10 +108,16 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
ctx := context.Background() ctx := context.Background()
structuredOutput := resp.Choices[0].Message.Content content := resp.Choices[0].Message.Content
if strings.HasPrefix(content, "```json") {
content = content[len("```json") : len(content)-3]
}
log.Info("", "res", content)
var createListArgs createNewListArguments var createListArgs createNewListArguments
err = json.Unmarshal([]byte(structuredOutput), &createListArgs) err = json.Unmarshal([]byte(content), &createListArgs)
if err != nil { if err != nil {
return err return err
} }
@ -113,6 +125,8 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
schemaItems := make([]model.SchemaItems, 0) schemaItems := make([]model.SchemaItems, 0)
for _, field := range createListArgs.Fields { for _, field := range createListArgs.Fields {
schemaItems = append(schemaItems, model.SchemaItems{ schemaItems = append(schemaItems, model.SchemaItems{
StackID: stackID,
Item: field.Name, Item: field.Name,
Description: field.Description, Description: field.Description,
@ -120,12 +134,7 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, user
}) })
} }
_, err = agent.listModel.Save(ctx, userID, createListArgs.Title, createListArgs.Description, model.Progress_Complete) err = agent.stackModel.SaveItems(ctx, schemaItems)
if err != nil {
return fmt.Errorf("creating list agent, saving list: %w", err)
}
err = agent.listModel.SaveItems(ctx, schemaItems)
if err != nil { if err != nil {
return fmt.Errorf("creating list agent, saving items: %w", err) return fmt.Errorf("creating list agent, saving items: %w", err)
} }

View File

@ -176,7 +176,7 @@ type addToListArguments struct {
Schema []models.IDValue Schema []models.IDValue
} }
func NewListAgent(log *log.Logger, stackModel models.StackModel, limitsMethods limits.LimitsManagerMethods) client.AgentClient { func NewStackAgent(log *log.Logger, stackModel models.StackModel, limitsMethods limits.LimitsManagerMethods) client.AgentClient {
agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{ agentClient := client.CreateAgentClient(client.CreateAgentClientOptions{
SystemPrompt: listPrompt, SystemPrompt: listPrompt,
JsonTools: listTools, JsonTools: listTools,

View File

@ -87,6 +87,7 @@ func (h *AuthHandler) code(body codeBody, w http.ResponseWriter, r *http.Request
} }
func (h *AuthHandler) refresh(body refreshBody, w http.ResponseWriter, r *http.Request) { func (h *AuthHandler) refresh(body refreshBody, w http.ResponseWriter, r *http.Request) {
h.logger.Info("token", "refresh", body.Refresh)
userId, err := h.jwtManager.GetUserIdFromRefresh(body.Refresh) userId, err := h.jwtManager.GetUserIdFromRefresh(body.Refresh)
if err != nil { if err != nil {
middleware.WriteErrorBadRequest(h.logger, "invalid refresh token: "+err.Error(), w) middleware.WriteErrorBadRequest(h.logger, "invalid refresh token: "+err.Error(), w)

View File

@ -142,6 +142,20 @@ func (m StackModel) SaveSchemaItems(ctx context.Context, imageID uuid.UUID, item
return err return err
} }
// ========================================
// UPDATE methods
// ========================================
func (m StackModel) UpdateProcess(ctx context.Context, stackID uuid.UUID, process model.Progress) error {
updateStackProgressStmt := Stacks.UPDATE(Stacks.Status).
SET(process).
WHERE(Stacks.ID.EQ(UUID(stackID)))
_, err := updateStackProgressStmt.ExecContext(ctx, m.dbPool)
return err
}
// ======================================== // ========================================
// DELETE methods // DELETE methods
// ======================================== // ========================================

View File

@ -60,7 +60,7 @@ func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserI
). ).
FROM( FROM(
Image. Image.
LEFT_JOIN(ImageStacks, ImageStacks.ImageID.EQ(ImageStacks.ID)), LEFT_JOIN(ImageStacks, ImageStacks.ImageID.EQ(Image.ID)),
). ).
WHERE(Image.UserID.EQ(UUID(userId))) WHERE(Image.UserID.EQ(UUID(userId)))

View File

@ -9,7 +9,7 @@ import (
const ( const (
IMAGE_TYPE = "image" IMAGE_TYPE = "image"
LIST_TYPE = "list" STACK_TYPE = "stack"
) )
type ImageNotification struct { type ImageNotification struct {
@ -21,18 +21,18 @@ type ImageNotification struct {
Status string Status string
} }
type ListNotification struct { type StackNotification struct {
Type string Type string
ListID uuid.UUID StackID uuid.UUID
Name string Name string
Status string Status string
} }
type Notification struct { type Notification struct {
image *ImageNotification image *ImageNotification
list *ListNotification stack *StackNotification
} }
func GetImageNotification(image ImageNotification) Notification { func GetImageNotification(image ImageNotification) Notification {
@ -41,9 +41,9 @@ func GetImageNotification(image ImageNotification) Notification {
} }
} }
func GetListNotification(list ListNotification) Notification { func GetStackNotification(list StackNotification) Notification {
return Notification{ return Notification{
list: &list, stack: &list,
} }
} }
@ -52,8 +52,8 @@ func (n Notification) MarshalJSON() ([]byte, error) {
return json.Marshal(n.image) return json.Marshal(n.image)
} }
if n.list != nil { if n.stack != nil {
return json.Marshal(n.list) return json.Marshal(n.stack)
} }
return nil, fmt.Errorf("no image or list present") return nil, fmt.Errorf("no image or list present")

View File

@ -23,11 +23,8 @@ type ImageProcessor struct {
descriptionAgent agents.DescriptionAgent descriptionAgent agents.DescriptionAgent
stackAgent client.AgentClient stackAgent client.AgentClient
// TODO: add the notifier here
Processor *Processor[model.Image] Processor *Processor[model.Image]
notifier *notifications.Notifier[notifications.Notification]
notifier *notifications.Notifier[notifications.Notification]
} }
func (p *ImageProcessor) setImageToProcess(ctx context.Context, image model.Image) { func (p *ImageProcessor) setImageToProcess(ctx context.Context, image model.Image) {
@ -43,6 +40,19 @@ func (p *ImageProcessor) setImageToProcess(ctx context.Context, image model.Imag
} }
} }
func (p *ImageProcessor) setImageToDone(ctx context.Context, image model.Image) {
err := p.imageModel.UpdateProcess(ctx, image.ID, model.Progress_Complete)
if err != nil {
// TODO: what can we actually do here for the errors?
// We can't stop the work for the others
p.logger.Error("failed to update image", "err", err)
// TODO: we can use context here to actually pass some information through
return
}
}
func (p *ImageProcessor) describe(ctx context.Context, image model.Image) { func (p *ImageProcessor) describe(ctx context.Context, image model.Image) {
descriptionSubLogger := p.logger.With("describe image", image.ID) descriptionSubLogger := p.logger.With("describe image", image.ID)
@ -100,6 +110,8 @@ func (p *ImageProcessor) processImage(image model.Image) {
wg.Wait() wg.Wait()
p.setImageToDone(ctx, image)
// TODO: there is some repeated code here. The ergonomicts of the notifications, // TODO: there is some repeated code here. The ergonomicts of the notifications,
// isn't the best. // isn't the best.
imageNotification = notifications.GetImageNotification(notifications.ImageNotification{ imageNotification = notifications.GetImageNotification(notifications.ImageNotification{
@ -128,7 +140,7 @@ func NewImageProcessor(
} }
descriptionAgent := agents.NewDescriptionAgent(logger, imageModel) descriptionAgent := agents.NewDescriptionAgent(logger, imageModel)
stackAgent := agents.NewListAgent(logger, listModel, limitsManager) stackAgent := agents.NewStackAgent(logger, listModel, limitsManager)
imageProcessor := ImageProcessor{ imageProcessor := ImageProcessor{
imageModel: imageModel, imageModel: imageModel,

142
backend/processor/stack.go Normal file
View File

@ -0,0 +1,142 @@
package processor
import (
"context"
"fmt"
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents"
"screenmark/screenmark/models"
"screenmark/screenmark/notifications"
"sync"
"github.com/charmbracelet/log"
)
const STACK_PROCESS_AT_A_TIME = 10
// TODO:
// This processor contains a lot of shared stuff.
// If we ever want to do more generic stuff with "in-progress" and stuff
// we can extract that into a common thing
//
// However, this will require a pretty big DB shuffle.
type StackProcessor struct {
stackModel models.StackModel
logger *log.Logger
stackAgent agents.CreateListAgent
Processor *Processor[model.Stacks]
notifier *notifications.Notifier[notifications.Notification]
}
func (p *StackProcessor) setStackToProcess(ctx context.Context, stack model.Stacks) {
err := p.stackModel.UpdateProcess(ctx, stack.ID, model.Progress_InProgress)
if err != nil {
// TODO: what can we actually do here for the errors?
// We can't stop the work for the others
p.logger.Error("failed to update stack", "err", err)
// TODO: we can use context here to actually pass some information through
return
}
}
func (p *StackProcessor) setStackToDone(ctx context.Context, stack model.Stacks) {
err := p.stackModel.UpdateProcess(ctx, stack.ID, model.Progress_Complete)
if err != nil {
// TODO: what can we actually do here for the errors?
// We can't stop the work for the others
p.logger.Error("failed to update stack", "err", err)
// TODO: we can use context here to actually pass some information through
return
}
}
func (p *StackProcessor) extractInfo(ctx context.Context, stack model.Stacks) {
err := p.stackAgent.CreateList(p.logger, stack.UserID, stack.ID, stack.Name, stack.Description)
if err != nil {
// Again, wtf do we do?
// Although i think the agent actually returns an error when it's finished
p.logger.Error("failed to process image", "err", err)
return
}
}
func (p *StackProcessor) processImage(stack model.Stacks) {
p.logger.Info("Processing image", "ID", stack.ID)
ctx := context.Background()
p.setStackToProcess(ctx, stack)
var wg sync.WaitGroup
// Future proofing!
wg.Add(1)
stackNotification := notifications.GetStackNotification(notifications.StackNotification{
Type: notifications.STACK_TYPE,
Status: string(model.Progress_InProgress),
StackID: stack.ID,
Name: stack.Name,
})
err := p.notifier.SendAndCreate(stack.UserID.String(), stackNotification)
if err != nil {
p.logger.Error("sending in progress notification", "err", err)
return
}
go func() {
p.extractInfo(ctx, stack)
wg.Done()
}()
wg.Wait()
p.setStackToDone(ctx, stack)
// TODO: there is some repeated code here. The ergonomicts of the notifications,
// isn't the best.
stackNotification = notifications.GetStackNotification(notifications.StackNotification{
Type: notifications.STACK_TYPE,
Status: string(model.Progress_Complete),
StackID: stack.ID,
Name: stack.Name,
})
err = p.notifier.SendAndCreate(stack.UserID.String(), stackNotification)
if err != nil {
p.logger.Error("sending done notification", "err", err)
return
}
}
func NewStackProcessor(
logger *log.Logger,
stackModel models.StackModel,
notifier *notifications.Notifier[notifications.Notification],
) (StackProcessor, error) {
if notifier == nil {
return StackProcessor{}, fmt.Errorf("notifier is nil")
}
stackAgent := agents.NewCreateListAgent(logger, stackModel)
imageProcessor := StackProcessor{
logger: logger,
stackModel: stackModel,
stackAgent: stackAgent,
notifier: notifier,
}
imageProcessor.Processor = NewProcessor(int(IMAGE_PROCESS_AT_A_TIME), imageProcessor.processImage)
return imageProcessor, nil
}

View File

@ -41,9 +41,16 @@ func setupRouter(db *sql.DB, jwtManager *ourmiddleware.JwtManager) (chi.Router,
return nil, fmt.Errorf("processor: %w", err) return nil, fmt.Errorf("processor: %w", err)
} }
go imageProcessor.Processor.Work() stackProcessorLog := createLogger("Stack Processor", os.Stdout)
stackProcessor, err := processor.NewStackProcessor(stackProcessorLog, stackModel, &notifier)
if err != nil {
return nil, fmt.Errorf("processor: %w", err)
}
stackHandler := stacks.CreateStackHandler(db, limitsManager, jwtManager) go imageProcessor.Processor.Work()
go stackProcessor.Processor.Work()
stackHandler := stacks.CreateStackHandler(db, limitsManager, jwtManager, stackProcessor.Processor)
authHandler := auth.CreateAuthHandler(db, jwtManager) authHandler := auth.CreateAuthHandler(db, jwtManager)
imageHandler := images.CreateImageHandler(db, limitsManager, jwtManager, imageProcessor.Processor) imageHandler := images.CreateImageHandler(db, limitsManager, jwtManager, imageProcessor.Processor)

View File

@ -45,7 +45,9 @@ CREATE TABLE haystack.image_stacks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
image_id UUID NOT NULL REFERENCES haystack.image (id) ON DELETE CASCADE, image_id UUID NOT NULL REFERENCES haystack.image (id) ON DELETE CASCADE,
stack_id UUID NOT NULL REFERENCES haystack.stacks (id) ON DELETE CASCADE stack_id UUID NOT NULL REFERENCES haystack.stacks (id) ON DELETE CASCADE,
UNIQUE(image_id, stack_id)
); );
CREATE TABLE haystack.schema_items ( CREATE TABLE haystack.schema_items (

View File

@ -8,6 +8,7 @@ import (
"screenmark/screenmark/limits" "screenmark/screenmark/limits"
"screenmark/screenmark/middleware" "screenmark/screenmark/middleware"
"screenmark/screenmark/models" "screenmark/screenmark/models"
"screenmark/screenmark/processor"
"github.com/charmbracelet/log" "github.com/charmbracelet/log"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -23,6 +24,8 @@ type StackHandler struct {
limitsManager limits.LimitsManagerMethods limitsManager limits.LimitsManagerMethods
jwtManager *middleware.JwtManager jwtManager *middleware.JwtManager
processor *processor.Processor[model.Stacks]
} }
func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) { func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) {
@ -50,7 +53,7 @@ func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) {
return return
} }
stackID, err := middleware.GetPathParamID(h.logger, "listID", w, r) stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
if err != nil { if err != nil {
return return
} }
@ -83,7 +86,7 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
return return
} }
stackID, err := middleware.GetPathParamID(h.logger, "listID", w, r) stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
if err != nil { if err != nil {
return return
} }
@ -147,10 +150,10 @@ func (h *StackHandler) deleteImageFromStack(w http.ResponseWriter, r *http.Reque
func (h *StackHandler) deleteImageStackSchemaItem(w http.ResponseWriter, r *http.Request) { func (h *StackHandler) deleteImageStackSchemaItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
stringImageID := chi.URLParam(r, "stackID") stringStackID := chi.URLParam(r, "stackID")
stringSchemaItemID := chi.URLParam(r, "schemaItemID") stringSchemaItemID := chi.URLParam(r, "schemaItemID")
imageID, err := uuid.Parse(stringImageID) stackID, err := uuid.Parse(stringStackID)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
return return
@ -169,8 +172,9 @@ func (h *StackHandler) deleteImageStackSchemaItem(w http.ResponseWriter, r *http
return return
} }
stack, err := h.stackModel.Get(ctx, schemaItemID) stack, err := h.stackModel.Get(ctx, stackID)
if err != nil { if err != nil {
h.logger.Error("could not get stack model", "err", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
@ -185,7 +189,7 @@ func (h *StackHandler) deleteImageStackSchemaItem(w http.ResponseWriter, r *http
// manipulations. So we could create a middleware. // manipulations. So we could create a middleware.
// If you repeat this 3 times, then organise it :) // If you repeat this 3 times, then organise it :)
err = h.stackModel.DeleteSchemaItem(ctx, schemaItemID, imageID) err = h.stackModel.DeleteSchemaItem(ctx, stackID, schemaItemID)
if err != nil { if err != nil {
h.logger.Warn("failed to delete image from list", "error", err) h.logger.Warn("failed to delete image from list", "error", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@ -208,14 +212,16 @@ func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter,
} }
// TODO: Add the stack processor here // TODO: Add the stack processor here
_, err = h.stackModel.Save(ctx, userID, body.Title, body.Description, model.Progress_NotStarted) stack, err := h.stackModel.Save(ctx, userID, body.Title, body.Description, model.Progress_NotStarted)
if err != nil { if err != nil {
h.logger.Warn("could not save stack", "err", err) h.logger.Warn("could not save stack", "err", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
w.WriteHeader(http.StatusOK) h.processor.Add(stack)
middleware.WriteJsonOrError(h.logger, stack, w)
} }
func (h *StackHandler) CreateRoutes(r chi.Router) { func (h *StackHandler) CreateRoutes(r chi.Router) {
@ -236,7 +242,12 @@ func (h *StackHandler) CreateRoutes(r chi.Router) {
}) })
} }
func CreateStackHandler(db *sql.DB, limitsManager limits.LimitsManagerMethods, jwtManager *middleware.JwtManager) StackHandler { func CreateStackHandler(
db *sql.DB,
limitsManager limits.LimitsManagerMethods,
jwtManager *middleware.JwtManager,
processor *processor.Processor[model.Stacks],
) StackHandler {
stackModel := models.NewStackModel(db) stackModel := models.NewStackModel(db)
imageModel := models.NewImageModel(db) imageModel := models.NewImageModel(db)
logger := log.New(os.Stdout).WithPrefix("Stacks") logger := log.New(os.Stdout).WithPrefix("Stacks")
@ -247,5 +258,6 @@ func CreateStackHandler(db *sql.DB, limitsManager limits.LimitsManagerMethods, j
stackModel: stackModel, stackModel: stackModel,
limitsManager: limitsManager, limitsManager: limitsManager,
jwtManager: jwtManager, jwtManager: jwtManager,
processor: processor,
} }
} }

View File

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.haystack.app</string>
</array>
</dict>
</plist>

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>Haystack</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsImage</key>
<true/>
<key>NSExtensionActivationSupportsMovie</key>
<false/>
<key>NSExtensionActivationSupportsText</key>
<false/>
<key>NSExtensionActivationSupportsURL</key>
<false/>
<key>NSExtensionActivationSupportsWebPageWithText</key>
<false/>
<key>NSExtensionActivationSupportsAttachmentsWithMaxCount</key>
<integer>0</integer>
<key>NSExtensionActivationSupportsAttachmentsWithMinCount</key>
<integer>1</integer>
</dict>
<key>NSExtensionMainStoryboard</key>
<string>MainInterface</string>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.ui-services</string>
<key>NSExtensionPrincipalClass</key>
<string>Haystack.ShareViewController</string>
</dict>
</dict>
</plist>

View File

@ -1,171 +0,0 @@
//
//  ShareViewController.swift
//  Haystack
//
//  Created by Rio Keefe on 03/05/2025.
//
import UIKit
import Social
import MobileCoreServices
class ShareViewController: SLComposeServiceViewController {
let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier
let tokenKey = "sharedAuthToken"
let uploadURL = URL(string: "https://haystack.johncosta.tech/image/")!
var bearerToken: String?
// Store the item provider to access it later in didSelectPost
private var imageItemProvider: NSItemProvider?
private var extractedImageName: String = "image" // Default name
override func viewDidLoad() {
super.viewDidLoad()
// Load the bearer token from the App Group in viewDidLoad
// This is okay as reading from UserDefaults is fast
if let sharedDefaults = UserDefaults(suiteName: appGroupName) {
bearerToken = sharedDefaults.string(forKey: tokenKey)
print("Retrieved bearer token: \(bearerToken ?? "nil")")
} else {
print("Error accessing App Group UserDefaults.")
// Optionally inform the user or disable posting if token is crucial
// self.isContentValid() could check if bearerToken is nil
}
// Store the item provider, but don't load the data synchronously yet
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
let provider = item.attachments?.first as? NSItemProvider {
if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
self.imageItemProvider = provider
// Attempt to get a suggested name early if available
extractedImageName = provider.suggestedName ?? "image"
if let dotRange = extractedImageName.range(of: ".", options: .backwards) {
extractedImageName = String(extractedImageName[..<dotRange.lowerBound])
}
} else {
print("No image found.")
// If no image is found, the content is not valid for this extension
// You might want to adjust isContentValid() based on this
}
}
}
override func isContentValid() -> Bool {
// Content is valid only if we have an item provider for an image AND a bearer token
return imageItemProvider != nil && bearerToken != nil
}
override func didSelectPost() {
// This method is called when the user taps the "Post" button.
// Start the asynchronous operation here.
guard let provider = imageItemProvider else {
print("Error: No image item provider found when posting.")
// Inform the user or log an error
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
return
}
guard let token = bearerToken else {
print("Error: Bearer token is missing when posting.")
// Inform the user or log an error
extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
return
}
// Load the image data asynchronously
provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
guard let self = self else { return }
if let error = error {
print("Error loading image data for upload: \(error.localizedDescription)")
// Inform the user about the failure
self.extensionContext!.cancelRequest(withError: error)
return
}
var rawImageData: Data?
var finalImageName = self.extractedImageName // Use the name extracted earlier
if let url = item as? URL, let data = try? Data(contentsOf: url) {
rawImageData = data
// Refine the name extraction here if necessary, though doing it in viewDidLoad is also an option
finalImageName = url.lastPathComponent
if let dotRange = finalImageName.range(of: ".", options: .backwards) {
finalImageName = String(finalImageName[..<dotRange.lowerBound])
}
} else if let data = item as? Data {
rawImageData = data
// Use the suggested name if available, fallback to default
finalImageName = provider.suggestedName ?? "image"
} else {
print("Error: Could not get image data in a usable format.")
// Inform the user about the failure
self.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: -4, userInfo: [NSLocalizedDescriptionKey: "Could not process image data."]))
return
}
guard let dataToUpload = rawImageData else {
print("Error: No image data to upload.")
// Inform the user about the failure
self.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: -5, userInfo: [NSLocalizedDescriptionKey: "Image data is missing."]))
return
}
// Now perform the upload asynchronously
self.uploadRawData(dataToUpload, imageName: finalImageName, bearerToken: token)
}
// Do not complete the request here.
// The request will be completed in the uploadRawData completion handler.
}
func uploadRawData(_ rawData: Data, imageName: String, bearerToken: String) {
// bearerToken is guaranteed to be non-nil here due to the guard in didSelectPost
let uploadURLwithName = uploadURL.appendingPathComponent(imageName)
var request = URLRequest(url: uploadURLwithName)
request.httpMethod = "POST"
request.httpBody = rawData
request.setValue("application/oclet-stream", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
// **IMPORTANT:** Complete the extension request on the main thread
DispatchQueue.main.async {
print("Upload finished. Error: \(error?.localizedDescription ?? "None"), Response: \(response?.description ?? "None")")
if let error = error {
// Handle upload error (e.g., show an alert to the user)
print("Upload failed: \(error.localizedDescription)")
self?.extensionContext!.cancelRequest(withError: error)
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
// Handle non-success HTTP status codes
let errorDescription = "Server returned status code \(httpResponse.statusCode)"
print(errorDescription)
self?.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorDescription]))
}
else {
// Upload was successful
print("Upload successful")
// Complete the request when the upload is done
self?.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
}
}
task.resume()
}
override func configurationItems() -> [Any]! {
// You can add items here if you want to allow the user to enter additional info
// e.g., a text field for a caption.
// This example only handles image upload, so no config items are needed.
return []
}
}

View File

@ -1,280 +1,189 @@
// //
// ShareViewController.swift //  ShareViewController.swift
// Haystack //  Haystack
// //
// Created by Rio Keefe on 03/05/2025. //  Created by Rio Keefe on 03/05/2025.
// //
import UIKit import UIKit
import Social import Social
import MobileCoreServices // For kUTTypeImage import MobileCoreServices
class ShareViewController: SLComposeServiceViewController { class ShareViewController: SLComposeServiceViewController {
let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier
let tokenKey = "sharedAuthToken" let tokenKey = "sharedAuthToken" // This key holds the refresh token.
let uploadURLBase = URL(string: "https://haystack.johncosta.tech/image/")! // Base URL let uploadURL = URL(string: "https://haystack.johncosta.tech/images/")!
var bearerToken: String? var refreshToken: String?
private var imageItemProvider: NSItemProvider? private var imageItemProvider: NSItemProvider?
// Store a base name, extension will be determined during item loading private var extractedImageName: String = "image" // Default name
private var baseImageName: String = "SharedImage" // A more descriptive default
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
if let sharedDefaults = UserDefaults(suiteName: appGroupName) { if let sharedDefaults = UserDefaults(suiteName: appGroupName) {
bearerToken = sharedDefaults.string(forKey: tokenKey) refreshToken = sharedDefaults.string(forKey: tokenKey)
print("Retrieved bearer token: \(bearerToken ?? "nil")") print("Retrieved refresh token: \(refreshToken ?? "nil")")
} else { } else {
print("Error accessing App Group UserDefaults.") print("Error accessing App Group UserDefaults.")
// Invalidate content if token is crucial and missing
// This will be caught by isContentValid()
} }
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem, // Store the item provider, but don't load the data synchronously yet
let provider = extensionItem.attachments?.first else { if let item = extensionContext?.inputItems.first as? NSExtensionItem,
print("No attachments found.") let provider = item.attachments?.first as? NSItemProvider {
// Invalidate content if no provider if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
self.imageItemProvider = nil // Ensure it's nil so isContentValid fails self.imageItemProvider = provider
return // Attempt to get a suggested name early if available
} extractedImageName = provider.suggestedName ?? "image"
if let dotRange = extractedImageName.range(of: ".", options: .backwards) {
extractedImageName = String(extractedImageName[..<dotRange.lowerBound])
}
if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) { } else {
self.imageItemProvider = provider print("No image found.")
// Attempt to get a suggested name early if available, and clean it. // If no image is found, the content is not valid for this extension
// This will be our default base name if the item itself doesn't provide a better one. // You might want to adjust isContentValid() based on this
if let suggested = provider.suggestedName, !suggested.isEmpty {
if let dotRange = suggested.range(of: ".", options: .backwards) {
self.baseImageName = String(suggested[..<dotRange.lowerBound])
} else {
self.baseImageName = suggested
}
} }
// Sanitize the base name slightly (remove problematic characters for a filename)
let anInvalidCharacters = CharacterSet(charactersIn: "/\\?%*|\"<>:")
self.baseImageName = self.baseImageName.components(separatedBy: anInvalidCharacters).joined(separator: "_")
if self.baseImageName.isEmpty { self.baseImageName = "SharedImage" } // Ensure not empty
} else {
print("Attachment is not an image.")
self.imageItemProvider = nil // Ensure it's nil so isContentValid fails
} }
} }
override func isContentValid() -> Bool { override func isContentValid() -> Bool {
// Content is valid only if we have an item provider for an image AND a bearer token // Content is valid only if we have an item provider for an image AND a refresh token
let isValid = imageItemProvider != nil && bearerToken != nil return imageItemProvider != nil && refreshToken != nil
if imageItemProvider == nil {
print("isContentValid: imageItemProvider is nil")
}
if bearerToken == nil {
print("isContentValid: bearerToken is nil")
}
return isValid
} }
override func didSelectPost() { override func didSelectPost() {
guard let provider = imageItemProvider else { refreshToken { accessToken in
print("Error: No image item provider found when posting.") guard let token = accessToken else {
informUserAndCancel(message: "No image found to share.") // Inform the user about the authentication failure
return let error = NSError(domain: "ShareExtension", code: -1, userInfo: [NSLocalizedDescriptionKey: "Authentication failed. Please log in again."])
} self.extensionContext!.cancelRequest(withError: error)
guard let token = bearerToken else {
print("Error: Bearer token is missing when posting.")
informUserAndCancel(message: "Authentication error. Please try again.")
return
}
// Start activity indicator or similar UI feedback
// For SLComposeServiceViewController, the system provides some UI
provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
guard let self = self else { return }
if let error = error {
print("Error loading image data for upload: \(error.localizedDescription)")
self.informUserAndCancel(message: "Could not load image: \(error.localizedDescription)")
return return
} }
var imageData: Data? guard let provider = self.imageItemProvider else {
var finalImageNameWithExtension: String print("Error: No image item provider found when posting.")
var mimeType: String = "application/octet-stream" // Default MIME type // Inform the user or log an error
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
// Determine base name (without extension)
var currentBaseName = self.baseImageName // Use the one prepared in viewDidLoad
if let suggested = provider.suggestedName, !suggested.isEmpty {
if let dotRange = suggested.range(of: ".", options: .backwards) {
currentBaseName = String(suggested[..<dotRange.lowerBound])
} else {
currentBaseName = suggested
}
let anInvalidCharacters = CharacterSet(charactersIn: "/\\?%*|\"<>:")
currentBaseName = currentBaseName.components(separatedBy: anInvalidCharacters).joined(separator: "_")
if currentBaseName.isEmpty { currentBaseName = "DefaultImageName" }
}
if let url = item as? URL {
print("Image provided as URL: \(url)")
finalImageNameWithExtension = url.lastPathComponent // Includes extension
// Ensure baseName is updated if URL provides a different one
if let dotRange = finalImageNameWithExtension.range(of:".", options: .backwards) {
currentBaseName = String(finalImageNameWithExtension[..<dotRange.lowerBound])
} else {
currentBaseName = finalImageNameWithExtension // No extension in name
}
do {
imageData = try Data(contentsOf: url)
// Determine MIME type from URL extension
let pathExtension = url.pathExtension.lowercased()
mimeType = self.mimeType(forPathExtension: pathExtension)
if !finalImageNameWithExtension.contains(".") && !pathExtension.isEmpty { // if original lastPathComponent had no ext
finalImageNameWithExtension = "\(currentBaseName).\(pathExtension)"
} else if !finalImageNameWithExtension.contains(".") && pathExtension.isEmpty { // no ext anywhere
finalImageNameWithExtension = "\(currentBaseName).jpg" // default to jpg
mimeType = "image/jpeg"
}
} catch {
print("Error creating Data from URL: \(error)")
self.informUserAndCancel(message: "Could not read image file.")
return
}
} else if let image = item as? UIImage {
print("Image provided as UIImage")
// Prefer PNG for screenshots/UIImage, fallback to JPEG
if let data = image.pngData() {
imageData = data
mimeType = "image/png"
finalImageNameWithExtension = "\(currentBaseName).png"
} else if let data = image.jpegData(compressionQuality: 0.9) { // Good quality JPEG
imageData = data
mimeType = "image/jpeg"
finalImageNameWithExtension = "\(currentBaseName).jpg"
} else {
print("Could not convert UIImage to Data.")
self.informUserAndCancel(message: "Could not process image.")
return
}
} else if let data = item as? Data {
print("Image provided as Data")
imageData = data
// We have raw data, try to use suggestedName's extension or default to png/jpg
var determinedExtension = "png" // Default
if let suggested = provider.suggestedName,
let dotRange = suggested.range(of: ".", options: .backwards) {
let ext = String(suggested[dotRange.upperBound...]).lowercased()
if ["png", "jpg", "jpeg", "gif"].contains(ext) {
determinedExtension = ext
}
}
mimeType = self.mimeType(forPathExtension: determinedExtension)
finalImageNameWithExtension = "\(currentBaseName).\(determinedExtension)"
} else {
print("Error: Could not get image data in a usable format. Item type: \(type(of: item)) Item: \(String(describing: item))")
self.informUserAndCancel(message: "Unsupported image format.")
return return
} }
guard let dataToUpload = imageData else { // Load the image data asynchronously
print("Error: No image data to upload after processing.") provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
self.informUserAndCancel(message: "Image data is missing.")
return
}
// Ensure finalImageNameWithExtension is not just an extension like ".png"
if finalImageNameWithExtension.starts(with: ".") {
finalImageNameWithExtension = "\(self.baseImageName)\(finalImageNameWithExtension)"
}
if finalImageNameWithExtension.isEmpty || !finalImageNameWithExtension.contains(".") {
// Fallback if somehow the name is bad
finalImageNameWithExtension = "\(self.baseImageName).png"
mimeType = "image/png"
}
print("Uploading image: \(finalImageNameWithExtension), MIME: \(mimeType), Size: \(dataToUpload.count) bytes")
self.uploadRawData(dataToUpload, imageNameWithExtension: finalImageNameWithExtension, mimeType: mimeType, bearerToken: token)
}
}
func uploadRawData(_ rawData: Data, imageNameWithExtension: String, mimeType: String, bearerToken: String) {
// The imageNameWithExtension should already include the correct extension.
// The server URL seems to expect the filename as a path component.
let uploadURL = uploadURLBase.appendingPathComponent(imageNameWithExtension)
print("Final Upload URL: \(uploadURL.absoluteString)")
var request = URLRequest(url: uploadURL)
request.httpMethod = "POST"
request.httpBody = rawData
request.setValue(mimeType, forHTTPHeaderField: "Content-Type") // Use determined MIME type
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
request.setValue("\(rawData.count)", forHTTPHeaderField: "Content-Length")
let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
DispatchQueue.main.async {
guard let self = self else { return } guard let self = self else { return }
print("Upload finished. Error: \(error?.localizedDescription ?? "None")")
if let httpResponse = response as? HTTPURLResponse {
print("HTTP Status: \(httpResponse.statusCode)")
if let responseData = data, let responseString = String(data: responseData, encoding: .utf8) {
print("Response Data: \(responseString)")
}
}
if let error = error { if let error = error {
print("Upload failed: \(error.localizedDescription)") print("Error loading image data for upload: \(error.localizedDescription)")
self.informUserAndCancel(message: "Upload failed: \(error.localizedDescription)") // Inform the user about the failure
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { self.extensionContext!.cancelRequest(withError: error)
let errorDescription = "Upload failed. Server returned status code \(httpResponse.statusCode)." return
print(errorDescription) }
self.informUserAndCancel(message: errorDescription)
var rawImageData: Data?
var finalImageName = self.extractedImageName // Use the name extracted earlier
if let url = item as? URL, let data = try? Data(contentsOf: url) {
rawImageData = data
// Refine the name extraction here if necessary, though doing it in viewDidLoad is also an option
finalImageName = url.lastPathComponent
if let dotRange = finalImageName.range(of: ".", options: .backwards) {
finalImageName = String(finalImageName[..<dotRange.lowerBound])
}
} else if let data = item as? Data {
rawImageData = data
// Use the suggested name if available, fallback to default
finalImageName = provider.suggestedName ?? "image"
} else { } else {
print("Upload successful for \(imageNameWithExtension)") print("Error: Could not get image data in a usable format.")
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil) // Inform the user about the failure
self.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: -4, userInfo: [NSLocalizedDescriptionKey: "Could not process image data."]))
return
}
guard let dataToUpload = rawImageData else {
print("Error: No image data to upload.")
// Inform the user about the failure
self.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: -5, userInfo: [NSLocalizedDescriptionKey: "Image data is missing."]))
return
}
// Now perform the upload asynchronously
self.uploadRawData(dataToUpload, imageName: finalImageName, bearerToken: token)
}
}
}
func uploadRawData(_ rawData: Data, imageName: String, bearerToken: String) {
// bearerToken is guaranteed to be non-nil here due to the guard in didSelectPost
let uploadURLwithName = uploadURL.appendingPathComponent(imageName)
var request = URLRequest(url: uploadURLwithName)
request.httpMethod = "POST"
request.httpBody = rawData
request.setValue("application/oclet-stream", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(bearerToken)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
// **IMPORTANT:** Complete the extension request on the main thread
DispatchQueue.main.async {
print("Upload finished. Error: \(error?.localizedDescription ?? "None"), Response: \(response?.description ?? "None")")
if let error = error {
// Handle upload error (e.g., show an alert to the user)
print("Upload failed: \(error.localizedDescription)")
self?.extensionContext!.cancelRequest(withError: error)
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
// Handle non-success HTTP status codes
let errorDescription = "Server returned status code \(httpResponse.statusCode)"
print(errorDescription)
self?.extensionContext!.cancelRequest(withError: NSError(domain: "ShareExtension", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorDescription]))
}
else {
// Upload was successful
print("Upload successful")
// Complete the request when the upload is done
self?.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
} }
} }
} }
task.resume() task.resume()
} }
override func configurationItems() -> [Any]! { func refreshToken(completion: @escaping (String?) -> Void) {
// No configuration items needed for this simple image uploader. guard let refreshToken = self.refreshToken else {
return [] completion(nil)
} return
}
// Helper to inform user and cancel request let url = URL(string: "https://haystack.johncosta.tech/auth/refresh")!
private func informUserAndCancel(message: String) { var request = URLRequest(url: url)
let error = NSError(domain: "com.haystack.ShareExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: message]) request.httpMethod = "POST"
print("Informing user: \(message)") request.setValue("application/json", forHTTPHeaderField: "Content-Type")
// You could present an alert here if SLComposeServiceViewController allows easy alert presentation.
// For now, just cancel the request. The system might show a generic error.
self.extensionContext!.cancelRequest(withError: error)
}
// Helper to get MIME type from path extension let body = ["refresh": refreshToken]
private func mimeType(forPathExtension pathExtension: String) -> String { request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension as CFString, nil)?.takeRetainedValue()
if let uti = uti { let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
let mimeType = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue() if let data = data,
if let mimeType = mimeType { let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
return mimeType as String let accessToken = json["access"] as? String {
completion(accessToken)
} else {
completion(nil)
} }
} }
// Fallback for common types if UTType fails or for robustness
switch pathExtension.lowercased() { task.resume()
// case "jpg", "jpeg": return "image/jpeg" }
// case "png": return "image/png"
// case "gif": return "image/gif" override func configurationItems() -> [Any]! {
// case "bmp": return "image/bmp" // You can add items here if you want to allow the user to enter additional info
// case "tiff", "tif": return "image/tiff" // e.g., a text field for a caption.
default: return "application/octet-stream" // Generic fallback // This example only handles image upload, so no config items are needed.
} return []
} }
} }

View File

@ -7,7 +7,7 @@ import {
Settings, Settings,
SearchPage, SearchPage,
AllImages, AllImages,
List, Stack,
} from "./pages"; } from "./pages";
import { SearchImageContextProvider } from "@contexts/SearchImageContext"; import { SearchImageContextProvider } from "@contexts/SearchImageContext";
import { WithNotifications } from "@contexts/Notifications"; import { WithNotifications } from "@contexts/Notifications";
@ -41,7 +41,7 @@ export const App = () => {
path="/image/:imageId" path="/image/:imageId"
component={ImagePage} component={ImagePage}
/> />
<Route path="/list/:listId" component={List} /> <Route path="/stack/:stackID" component={Stack} />
<Route path="/settings" component={Settings} /> <Route path="/settings" component={Settings} />
</Route> </Route>
</Route> </Route>

View File

@ -9,7 +9,7 @@ export const ProcessingImages: Component = () => {
const processingNumber = () => const processingNumber = () =>
Object.keys(notifications.state.ProcessingImages).length + Object.keys(notifications.state.ProcessingImages).length +
Object.keys(notifications.state.ProcessingLists).length; Object.keys(notifications.state.ProcessingStacks).length;
const [accessToken] = createResource(getAccessToken) const [accessToken] = createResource(getAccessToken)
@ -60,13 +60,13 @@ export const ProcessingImages: Component = () => {
)} )}
</For> </For>
<For each={Object.entries(notifications.state.ProcessingLists)}> <For each={Object.entries(notifications.state.ProcessingStacks)}>
{([, _list]) => ( {([, _stack]) => (
<Show when={_list}> <Show when={_stack}>
{(list) => ( {(stack) => (
<div class="flex gap-2 w-full justify-center"> <div class="flex gap-2 w-full justify-center">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<p class="text-slate-900">New Stack: {list().Name}</p> <p class="text-slate-900">New Stack: {stack().Name}</p>
</div> </div>
<LoadingCircle <LoadingCircle
status="loading" status="loading"

View File

@ -1,8 +1,6 @@
import { Navigate } from "@solidjs/router"; import { Navigate } from "@solidjs/router";
import { platform } from "@tauri-apps/plugin-os";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { Component, ParentProps, Show } from "solid-js"; import { Component, ParentProps, Show } from "solid-js";
import { save_token } from "tauri-plugin-ios-shared-token-api";
import { InferOutput, literal, number, object, parse, pipe, string, transform } from "valibot"; import { InferOutput, literal, number, object, parse, pipe, string, transform } from "valibot";
export const isTokenValid = (): boolean => { export const isTokenValid = (): boolean => {
@ -36,19 +34,10 @@ export const ProtectedRoute: Component<ParentProps> = (props) => {
const isValid = isTokenValid(); const isValid = isTokenValid();
if (isValid) { if (isValid) {
const token = localStorage.getItem("access"); const token = localStorage.getItem("refresh");
if (token == null) { if (token == null) {
throw new Error("unreachable"); throw new Error("unreachable");
} }
if (platform() === "ios") {
// iOS share extension is a seperate process to the App.
// Therefore, we need to share our access token somewhere both the App & Share Extension can access
// This involves App Groups.
save_token(token)
.then(() => console.log("Saved token!!!"))
.catch((e) => console.error(e));
}
} }
return ( return (

View File

@ -1,4 +1,4 @@
import { List } from "@network/index"; import { Stack } from "@network/index";
import { Component } from "solid-js"; import { Component } from "solid-js";
import fastHashCode from "../../utils/hash"; import fastHashCode from "../../utils/hash";
import { A } from "@solidjs/router"; import { A } from "@solidjs/router";
@ -17,19 +17,19 @@ const colors = [
"bg-pink-50", "bg-pink-50",
]; ];
export const StackCard: Component<{ list: List }> = (props) => { export const StackCard: Component<{ stack: Stack }> = (props) => {
return ( return (
<A <A
href={`/list/${props.list.ID}`} href={`/stack/${props.stack.ID}`}
class={ class={
"flex flex-col p-4 border border-neutral-200 rounded-lg " + "flex flex-col p-4 border border-neutral-200 rounded-lg " +
colors[ colors[
fastHashCode(props.list.Name, { forcePositive: true }) % colors.length fastHashCode(props.stack.Name, { forcePositive: true }) % colors.length
] ]
} }
> >
<p class="text-xl font-bold">{props.list.Name}</p> <p class="text-xl font-bold">{props.stack.Name}</p>
<p class="text-lg">{props.list.Images.length}</p> <p class="text-lg">{props.stack.Images.length}</p>
</A> </A>
); );
}; };

View File

@ -22,7 +22,7 @@ type NotificationState = {
string, string,
InferOutput<typeof processingImagesValidator> | undefined InferOutput<typeof processingImagesValidator> | undefined
>; >;
ProcessingLists: Record< ProcessingStacks: Record<
string, string,
InferOutput<typeof processingListValidator> | undefined InferOutput<typeof processingListValidator> | undefined
>; >;
@ -31,7 +31,7 @@ type NotificationState = {
export const Notifications = (onCompleteImage: () => void) => { export const Notifications = (onCompleteImage: () => void) => {
const [state, setState] = createStore<NotificationState>({ const [state, setState] = createStore<NotificationState>({
ProcessingImages: {}, ProcessingImages: {},
ProcessingLists: {}, ProcessingStacks: {},
}); });
const { userImages } = useSearchImageContext(); const { userImages } = useSearchImageContext();
@ -39,8 +39,6 @@ export const Notifications = (onCompleteImage: () => void) => {
const [accessToken] = createResource(getAccessToken); const [accessToken] = createResource(getAccessToken);
const dataEventListener = (e: MessageEvent<unknown>) => { const dataEventListener = (e: MessageEvent<unknown>) => {
debugger;
if (typeof e.data !== "string") { if (typeof e.data !== "string") {
console.error("Error type is not string"); console.error("Error type is not string");
return; return;
@ -71,14 +69,14 @@ export const Notifications = (onCompleteImage: () => void) => {
} else { } else {
setState("ProcessingImages", ImageID, notification.output); setState("ProcessingImages", ImageID, notification.output);
} }
} else if (notification.output.Type === "list") { } else if (notification.output.Type === "stack") {
const { ListID, Status } = notification.output; const { StackID, Status } = notification.output;
if (Status === "complete") { if (Status === "complete") {
setState("ProcessingLists", ListID, undefined); setState("ProcessingStacks", StackID, undefined);
onCompleteImage(); onCompleteImage();
} else { } else {
setState("ProcessingLists", ListID, notification.output); setState("ProcessingStacks", StackID, notification.output);
} }
} }
}; };
@ -100,7 +98,7 @@ export const Notifications = (onCompleteImage: () => void) => {
upsertImageProcessing( upsertImageProcessing(
Object.fromEntries( Object.fromEntries(
images.filter(i => i.Status === 'complete').map((i) => [ images.filter(i => i.Status !== 'complete').map((i) => [
i.ID, i.ID,
{ {
Type: "image", Type: "image",

View File

@ -7,7 +7,14 @@ import {
createResource, createResource,
useContext, useContext,
} from "solid-js"; } from "solid-js";
import { deleteImage, deleteImageFromStack, getUserImages, JustTheImageWhatAreTheseNames } from "../network"; import {
deleteImage,
deleteImageFromStack,
deleteStack,
deleteStackItem,
getUserImages,
JustTheImageWhatAreTheseNames,
} from "../network";
export type SearchImageStore = { export type SearchImageStore = {
imagesByDate: Accessor< imagesByDate: Accessor<
@ -19,42 +26,46 @@ export type SearchImageStore = {
userImages: Accessor<JustTheImageWhatAreTheseNames>; userImages: Accessor<JustTheImageWhatAreTheseNames>;
onRefetchImages: () => void; onRefetchImages: () => void;
onDeleteImage: (imageID: string) => void; onDeleteImage: (imageID: string) => void;
onDeleteImageFromStack: (stackID: string, imageID: string) => void; onDeleteImageFromStack: (stackID: string, imageID: string) => void;
onDeleteStack: (stackID: string) => void;
onDeleteStackItem: (stackID: string, schemaItemID: string) => void;
}; };
const SearchImageContext = createContext<SearchImageStore>(); const SearchImageContext = createContext<SearchImageStore>();
export const SearchImageContextProvider: Component<ParentProps> = (props) => { export const SearchImageContextProvider: Component<ParentProps> = (props) => {
const [data, { refetch }] = createResource(getUserImages); const [data, { refetch }] = createResource(getUserImages);
const sortedImages = createMemo<ReturnType<SearchImageStore["imagesByDate"]>>( const sortedImages = createMemo<
() => { ReturnType<SearchImageStore["imagesByDate"]>
const d = data(); >(() => {
if (d == null) { const d = data();
return []; if (d == null) {
return [];
}
// Sorted by day. But we could potentially add more in the future.
const buckets: Record<string, JustTheImageWhatAreTheseNames> = {};
for (const image of d.UserImages) {
if (image.CreatedAt == null) {
continue;
} }
// Sorted by day. But we could potentially add more in the future. const date = new Date(image.CreatedAt).toDateString();
const buckets: Record<string, JustTheImageWhatAreTheseNames> = {}; if (!(date in buckets)) {
buckets[date] = [];
for (const image of d.UserImages) {
if (image.CreatedAt == null) {
continue;
}
const date = new Date(image.CreatedAt).toDateString();
if (!(date in buckets)) {
buckets[date] = [];
}
buckets[date].push(image);
} }
return Object.entries(buckets) buckets[date].push(image);
.map(([date, images]) => ({ date: new Date(date), images })) }
.sort((a, b) => b.date.getTime() - a.date.getTime());
}, return Object.entries(buckets)
); .map(([date, images]) => ({ date: new Date(date), images }))
.sort((a, b) => b.date.getTime() - a.date.getTime());
});
return ( return (
<SearchImageContext.Provider <SearchImageContext.Provider
@ -68,7 +79,13 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
}, },
onDeleteImageFromStack: (stackID: string, imageID: string) => { onDeleteImageFromStack: (stackID: string, imageID: string) => {
deleteImageFromStack(stackID, imageID).then(refetch); deleteImageFromStack(stackID, imageID).then(refetch);
} },
onDeleteStack: (stackID: string) => {
deleteStack(stackID).then(refetch)
},
onDeleteStackItem: (stackID: string, schemaItemID: string) => {
deleteStackItem(stackID, schemaItemID).then(refetch);
},
}} }}
> >
{props.children} {props.children}

View File

@ -1,5 +1,7 @@
import { getTokenProperties } from "@components/protected-route"; import { getTokenProperties } from "@components/protected-route";
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import { platform } from "@tauri-apps/plugin-os";
import { save_token } from "tauri-plugin-ios-shared-token-api";
import { import {
type InferOutput, type InferOutput,
@ -23,8 +25,8 @@ type BaseRequestParams = Partial<{
method: "GET" | "POST" | "DELETE"; method: "GET" | "POST" | "DELETE";
}>; }>;
// export const base = "https://haystack.johncosta.tech"; export const base = "https://haystack.johncosta.tech";
export const base = "http://localhost:3040"; // export const base = "http://192.168.1.199:3040";
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => { const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
return new Request(`${base}/${path}`, { return new Request(`${base}/${path}`, {
@ -42,25 +44,53 @@ export const getAccessToken = async (): Promise<string> => {
const refreshToken = localStorage.getItem("refresh")?.toString(); const refreshToken = localStorage.getItem("refresh")?.toString();
if (accessToken == null && refreshToken == null) { if (accessToken == null && refreshToken == null) {
throw new Error("your are not logged in") throw new Error("you are not logged in")
} }
const isValidAccessToken = accessToken != null && getTokenProperties(accessToken).exp.getTime() * 1000 > Date.now()
if (platform() === "ios") {
// iOS share extension is a seperate process to the App.
// Therefore, we need to share our access token somewhere both the App & Share Extension can access
// This involves App Groups.
save_token(refreshToken!)
.then(() => console.log("Saved token!!!"))
.catch(console.error);
}
// FIX: Check what getTokenProperties returns
const tokenProps = getTokenProperties(accessToken!);
// If tokenProps.exp is a number (seconds), convert to milliseconds:
const expiryTime = typeof tokenProps.exp === 'number'
? tokenProps.exp * 1000 // Convert seconds to milliseconds
: tokenProps.exp.getTime(); // Already a Date object
const isValidAccessToken = accessToken != null && expiryTime > Date.now();
console.log('Token check:', {
expiryTime: new Date(expiryTime),
now: new Date(),
isValid: isValidAccessToken,
timeLeft: (expiryTime - Date.now()) / 1000 + 's'
});
if (!isValidAccessToken) { if (!isValidAccessToken) {
console.log('Refreshing token...');
const newAccessToken = await fetch(getBaseRequest({ const newAccessToken = await fetch(getBaseRequest({
path: 'auth/refresh', method: "POST", body: JSON.stringify({ path: 'auth/refresh',
method: "POST",
body: JSON.stringify({
refresh: refreshToken, refresh: refreshToken,
}) })
})).then(r => r.json()); })).then(r => r.json());
const { access } = parse(refreshTokenValidator, newAccessToken); const { access } = parse(refreshTokenValidator, newAccessToken);
localStorage.setItem("access", access); localStorage.setItem("access", access);
accessToken = access accessToken = access;
} }
return accessToken! return accessToken!;
} }
const getBaseAuthorizedRequest = async ({ const getBaseAuthorizedRequest = async ({
@ -121,7 +151,19 @@ export const deleteImageFromStack = async (listID: string, imageID: string): Pro
await fetch(request); await fetch(request);
} }
export const deleteList = async (listID: string): Promise<void> => { export const deleteStackItem = async (
stackID: string,
schemaItemID: string,
): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `stacks/${stackID}/${schemaItemID}`,
method: "DELETE",
});
await fetch(request);
}
export const deleteStack = async (listID: string): Promise<void> => {
const request = await getBaseAuthorizedRequest({ const request = await getBaseAuthorizedRequest({
path: `stacks/${listID}`, path: `stacks/${listID}`,
method: "DELETE", method: "DELETE",
@ -182,7 +224,7 @@ const userImageValidator = strictObject({
strictObject({ strictObject({
ID: pipe(string(), uuid()), ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()), ImageID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()), StackID: pipe(string(), uuid()),
}), }),
)), transform(l => l ?? [])), )), transform(l => l ?? [])),
}); });
@ -226,7 +268,7 @@ const stackValidator = strictObject({
SchemaItems: array(stackSchemaItem), SchemaItems: array(stackSchemaItem),
}); });
export type List = InferOutput<typeof stackValidator>; export type Stack = InferOutput<typeof stackValidator>;
const imageRequestValidator = strictObject({ const imageRequestValidator = strictObject({
UserImages: array(userImageValidator), UserImages: array(userImageValidator),
@ -291,13 +333,13 @@ export const postCode = async (
return parsedRes.output; return parsedRes.output;
}; };
export class ReachedListLimit extends Error { export class ReachedStackLimit extends Error {
constructor() { constructor() {
super(); super();
} }
} }
export const createList = async ( export const createStack = async (
title: string, title: string,
description: string, description: string,
): Promise<void> => { ): Promise<void> => {
@ -311,6 +353,6 @@ export const createList = async (
const res = await fetch(request); const res = await fetch(request);
if (!res.ok && res.status == 429) { if (!res.ok && res.status == 429) {
throw new ReachedListLimit(); throw new ReachedStackLimit();
} }
}; };

View File

@ -1,31 +1,31 @@
import { literal, pipe, strictObject, string, union, uuid } from "valibot"; import { literal, pipe, strictObject, string, union, uuid } from "valibot";
export const processingListValidator = strictObject({ export const processingListValidator = strictObject({
Type: literal("list"), Type: literal("stack"),
Name: string(), Name: string(),
ListID: pipe(string(), uuid()), StackID: pipe(string(), uuid()),
Status: union([ Status: union([
literal("not-started"), literal("not-started"),
literal("in-progress"), literal("in-progress"),
literal("complete"), literal("complete"),
]), ]),
}); });
export const processingImagesValidator = strictObject({ export const processingImagesValidator = strictObject({
Type: literal("image"), Type: literal("image"),
ImageID: pipe(string(), uuid()), ImageID: pipe(string(), uuid()),
ImageName: string(), ImageName: string(),
Status: union([ Status: union([
literal("not-started"), literal("not-started"),
literal("in-progress"), literal("in-progress"),
literal("complete"), literal("complete"),
]), ]),
}); });
export const notificationValidator = union([ export const notificationValidator = union([
processingListValidator, processingListValidator,
processingImagesValidator, processingImagesValidator,
]); ]);

View File

@ -1,10 +1,10 @@
import { Component, For, createSignal } from "solid-js"; import { Component, For, createSignal } from "solid-js";
import { useSearchImageContext } from "@contexts/SearchImageContext"; import { useSearchImageContext } from "@contexts/SearchImageContext";
import { StackCard } from "@components/list-card";
import { Button } from "@kobalte/core/button"; import { Button } from "@kobalte/core/button";
import { Dialog } from "@kobalte/core/dialog"; import { Dialog } from "@kobalte/core/dialog";
import { createList, ReachedListLimit } from "../../network"; import { createStack, ReachedStackLimit } from "../../network";
import { createToast } from "../../utils/show-toast"; import { createToast } from "../../utils/show-toast";
import { StackCard } from "@components/stack-card";
export const Categories: Component = () => { export const Categories: Component = () => {
const { stacks, onRefetchImages } = useSearchImageContext(); const { stacks, onRefetchImages } = useSearchImageContext();
@ -15,20 +15,20 @@ export const Categories: Component = () => {
const [isCreating, setIsCreating] = createSignal(false); const [isCreating, setIsCreating] = createSignal(false);
const [showForm, setShowForm] = createSignal(false); const [showForm, setShowForm] = createSignal(false);
const handleCreateList = async () => { const handleCreatestack = async () => {
if (description().trim().length === 0 || title().trim().length === 0) if (description().trim().length === 0 || title().trim().length === 0)
return; return;
setIsCreating(true); setIsCreating(true);
try { try {
await createList(title().trim(), description().trim()); await createStack(title().trim(), description().trim());
setTitle(""); setTitle("");
setDescription(""); setDescription("");
setShowForm(false); setShowForm(false);
onRefetchImages(); // Refresh the stacks onRefetchImages(); // Refresh the stacks
} catch (error) { } catch (error) {
console.error("Failed to create list:", error); console.error("Failed to create stack:", error);
if (error instanceof ReachedListLimit) { if (error instanceof ReachedStackLimit) {
createToast("Reached limit!", "You've reached your limit for new stacks"); createToast("Reached limit!", "You've reached your limit for new stacks");
} }
} finally { } finally {
@ -39,8 +39,8 @@ export const Categories: Component = () => {
return ( return (
<div class="rounded-xl bg-white p-4 flex flex-col gap-2"> <div class="rounded-xl bg-white p-4 flex flex-col gap-2">
<h2 class="text-xl font-bold">Generated stacks</h2> <h2 class="text-xl font-bold">Generated stacks</h2>
<div class="w-full grid grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4"> <div class="w-full grid grid-cols-1 md:grid-cols-3 auto-rows-[minmax(100px,1fr)] gap-4">
<For each={stacks()}>{(list) => <StackCard list={list} />}</For> <For each={stacks()}>{(stack) => <StackCard stack={stack} />}</For>
</div> </div>
<div class="mt-4"> <div class="mt-4">
@ -48,7 +48,7 @@ export const Categories: Component = () => {
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium shadow-sm hover:shadow-md" class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors font-medium shadow-sm hover:shadow-md"
onClick={() => setShowForm(true)} onClick={() => setShowForm(true)}
> >
+ Create List + Create stack
</Button> </Button>
</div> </div>
@ -59,25 +59,25 @@ export const Categories: Component = () => {
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto"> <Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div class="p-6"> <div class="p-6">
<Dialog.Title class="text-xl font-bold text-neutral-900 mb-4"> <Dialog.Title class="text-xl font-bold text-neutral-900 mb-4">
Create New List Create New stack
</Dialog.Title> </Dialog.Title>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label <label
for="list-title" for="stack-title"
class="block text-sm font-medium text-neutral-700 mb-2" class="block text-sm font-medium text-neutral-700 mb-2"
> >
List Title stack Title
</label> </label>
<input <input
id="list-title" id="stack-title"
type="text" type="text"
value={title()} value={title()}
onInput={(e) => onInput={(e) =>
setTitle(e.target.value) setTitle(e.target.value)
} }
placeholder="Enter a title for your list" placeholder="Enter a title for your stack"
class="w-full p-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors" class="w-full p-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
disabled={isCreating()} disabled={isCreating()}
/> />
@ -85,18 +85,18 @@ export const Categories: Component = () => {
<div> <div>
<label <label
for="list-description" for="stack-description"
class="block text-sm font-medium text-neutral-700 mb-2" class="block text-sm font-medium text-neutral-700 mb-2"
> >
List Description stack Description
</label> </label>
<textarea <textarea
id="list-description" id="stack-description"
value={description()} value={description()}
onInput={(e) => onInput={(e) =>
setDescription(e.target.value) setDescription(e.target.value)
} }
placeholder="Describe what kind of list you want to create (e.g., 'A list of my favorite recipes' or 'Photos from my vacation')" placeholder="Describe what kind of stack you want to create (e.g., 'A list of my favorite recipes' or 'Photos from my vacation')"
class="w-full p-3 border border-neutral-300 rounded-lg resize-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors" class="w-full p-3 border border-neutral-300 rounded-lg resize-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
rows="4" rows="4"
disabled={isCreating()} disabled={isCreating()}
@ -107,7 +107,7 @@ export const Categories: Component = () => {
<div class="flex gap-3 mt-6"> <div class="flex gap-3 mt-6">
<Button <Button
class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 font-medium shadow-sm hover:shadow-md" class="flex-1 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 font-medium shadow-sm hover:shadow-md"
onClick={handleCreateList} onClick={handleCreatestack}
disabled={ disabled={
isCreating() || isCreating() ||
!title().trim() || !title().trim() ||
@ -116,7 +116,7 @@ export const Categories: Component = () => {
> >
{isCreating() {isCreating()
? "Creating..." ? "Creating..."
: "Create List"} : "Create stack"}
</Button> </Button>
<Button <Button
class="px-4 py-2 bg-neutral-300 text-neutral-700 rounded-lg hover:bg-neutral-400 transition-colors font-medium" class="px-4 py-2 bg-neutral-300 text-neutral-700 rounded-lg hover:bg-neutral-400 transition-colors font-medium"

View File

@ -18,7 +18,7 @@ export const Recent: Component = () => {
return ( return (
<div class="rounded-xl bg-white p-4 flex flex-col gap-2"> <div class="rounded-xl bg-white p-4 flex flex-col gap-2">
<h2 class="text-xl font-bold">Recent Screenshots</h2> <h2 class="text-xl font-bold">Recent Screenshots</h2>
<div class="grid grid-cols-3 gap-4 place-items-center"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 place-items-center">
<For each={latestImages()}> <For each={latestImages()}>
{(image) => <ImageComponent ID={image.ID} onDelete={onDeleteImage} />} {(image) => <ImageComponent ID={image.ID} onDelete={onDeleteImage} />}
</For> </For>

View File

@ -1,9 +1,9 @@
import { ImageComponentFullHeight } from "@components/image"; import { ImageComponentFullHeight } from "@components/image";
import { StackCard } from "@components/stack-card";
import { useSearchImageContext } from "@contexts/SearchImageContext"; import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useNavigate, useParams } from "@solidjs/router"; import { useNavigate, useParams } from "@solidjs/router";
import { For, type Component } from "solid-js"; import { For, type Component } from "solid-js";
import SolidjsMarkdown from "solidjs-markdown"; import SolidjsMarkdown from "solidjs-markdown";
import { StackCard } from "@components/list-card";
export const ImagePage: Component = () => { export const ImagePage: Component = () => {
const { imageId } = useParams<{ imageId: string }>(); const { imageId } = useParams<{ imageId: string }>();
@ -22,12 +22,12 @@ export const ImagePage: Component = () => {
}} /> }} />
</div> </div>
<div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4"> <div class="w-full bg-white rounded-xl p-4 flex flex-col gap-4">
<h2 class="font-bold text-2xl">Description</h2> <h2 class="font-bold text-2xl">Stacks</h2>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
<For each={image()?.ImageStacks}> <For each={image()?.ImageStacks}>
{(imageList) => ( {(imageList) => (
<StackCard <StackCard
list={stacks().find((l) => l.ID === imageList.ListID)!} stack={stacks().find((l) => l.ID === imageList.StackID)!}
/> />
)} )}
</For> </For>

View File

@ -4,4 +4,4 @@ export * from "./settings";
export * from "./login"; export * from "./login";
export * from "./search"; export * from "./search";
export * from "./all-images"; export * from "./all-images";
export * from "./list"; export * from "./stack";

View File

@ -2,11 +2,11 @@ import { useSearchImageContext } from "@contexts/SearchImageContext";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
export const useSearch = () => { export const useSearch = () => {
const { userImages } = useSearchImageContext(); const { userImages } = useSearchImageContext();
return () => return () =>
new Fuse(userImages(), { new Fuse(userImages(), {
shouldSort: true, shouldSort: true,
keys: ["Image.Description"], keys: ["Description"],
}); });
}; };

View File

@ -1,4 +1,3 @@
import { useSearchImageContext } from "@contexts/SearchImageContext";
import { useParams, useNavigate } from "@solidjs/router"; import { useParams, useNavigate } from "@solidjs/router";
import { import {
Component, Component,
@ -8,8 +7,9 @@ import {
createResource, createResource,
createSignal, createSignal,
} from "solid-js"; } from "solid-js";
import { base, deleteList, getAccessToken } from "../../network"; import { base, getAccessToken } from "../../network";
import { Dialog } from "@kobalte/core"; import { Dialog } from "@kobalte/core";
import { useSearchImageContext } from "@contexts/SearchImageContext";
const DeleteButton: Component<{ onDelete: () => void }> = (props) => { const DeleteButton: Component<{ onDelete: () => void }> = (props) => {
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
@ -57,7 +57,7 @@ const DeleteButton: Component<{ onDelete: () => void }> = (props) => {
); );
}; };
const DeleteListButton: Component<{ onDelete: () => void }> = (props) => { const DeleteStackButton: Component<{ onDelete: () => void }> = (props) => {
const [isOpen, setIsOpen] = createSignal(false); const [isOpen, setIsOpen] = createSignal(false);
return ( return (
@ -67,7 +67,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
class="text-white bg-red-600 hover:bg-red-700 rounded px-3 py-2 text-sm font-medium" class="text-white bg-red-600 hover:bg-red-700 rounded px-3 py-2 text-sm font-medium"
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
> >
Delete List Delete Stack
</button> </button>
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}> <Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
@ -76,7 +76,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
<div class="fixed inset-0 z-50 flex items-center justify-center p-4"> <div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full p-6"> <Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<Dialog.Title class="text-lg font-bold mb-2"> <Dialog.Title class="text-lg font-bold mb-2">
Confirm Delete List Confirm Delete Stack
</Dialog.Title> </Dialog.Title>
<Dialog.Description class="mb-4"> <Dialog.Description class="mb-4">
Are you sure you want to delete this entire Are you sure you want to delete this entire
@ -92,7 +92,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700" class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
onClick={props.onDelete} onClick={props.onDelete}
> >
Delete List Delete Stack
</button> </button>
</div> </div>
</Dialog.Content> </Dialog.Content>
@ -103,39 +103,88 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
); );
}; };
export const List: Component = () => { const DeleteStackItemButton: Component<{ onDelete: () => void }> = (props) => {
const { listId } = useParams(); const [isOpen, setIsOpen] = createSignal(false);
return (
<>
<button
aria-label="Delete schema item"
class="text-gray-500 hover:text-red-700 text-sm"
onClick={() => setIsOpen(true)}
>
×
</button>
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black bg-opacity-50" />
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<Dialog.Content class="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<Dialog.Title class="text-lg font-bold mb-2">
Confirm Delete
</Dialog.Title>
<Dialog.Description class="mb-4">
Are you sure you want to delete this column from
this list?
</Dialog.Description>
<div class="flex justify-end gap-2">
<Dialog.CloseButton>
<button class="px-4 py-2 bg-gray-300 rounded hover:bg-gray-400">
Cancel
</button>
</Dialog.CloseButton>
<button
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
onClick={props.onDelete}
>
Confirm
</button>
</div>
</Dialog.Content>
</div>
</Dialog.Portal>
</Dialog.Root>
</>
);
};
export const Stack: Component = () => {
const { stackID } = useParams();
const nav = useNavigate(); const nav = useNavigate();
const { stacks, onDeleteImageFromStack } = useSearchImageContext(); const { stacks, onDeleteImageFromStack, onDeleteStack, onDeleteStackItem } =
useSearchImageContext();
const [accessToken] = createResource(getAccessToken); const [accessToken] = createResource(getAccessToken);
const list = () => stacks().find((l) => l.ID === listId); const stack = () => stacks().find((l) => l.ID === stackID);
const handleDeleteList = async () => { const handleDeleteStack = async () => {
await deleteList(listId) onDeleteStack(stackID);
nav("/"); nav("/");
}; };
return ( return (
<Suspense> <Suspense>
<Show when={list()} fallback="List could not be found"> <Show when={stack()} fallback="Stack could not be found">
{(l) => ( {(s) => (
<div class="w-full h-full bg-white rounded-lg shadow-sm border border-neutral-200 overflow-hidden"> <div class="w-full h-full bg-white rounded-lg shadow-sm border border-neutral-200 overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-200 bg-neutral-50"> <div class="px-6 py-4 border-b border-neutral-200 bg-neutral-50">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<h1 class="text-xl font-semibold text-neutral-900"> <h1 class="text-xl font-semibold text-neutral-900">
{l().Name} {s().Name}
</h1> </h1>
<Show when={l().Description}> <Show when={s().Description}>
<p class="text-sm text-neutral-600 mt-1"> <p class="text-sm text-neutral-600 mt-1">
{l().Description} {s().Description}
</p> </p>
</Show> </Show>
</div> </div>
<DeleteListButton onDelete={handleDeleteList} /> <DeleteStackButton
onDelete={handleDeleteStack}
/>
</div> </div>
</div> </div>
<div <div
@ -148,31 +197,42 @@ export const List: Component = () => {
<th class="px-6 py-4 text-left text-sm font-semibold text-neutral-900 border-r border-neutral-200 min-w-40"> <th class="px-6 py-4 text-left text-sm font-semibold text-neutral-900 border-r border-neutral-200 min-w-40">
Image Image
</th> </th>
<For each={l().SchemaItems}> <For each={s().SchemaItems}>
{(item, index) => ( {(item, index) => (
<th <th
class={`px-6 py-4 text-left text-sm font-semibold text-neutral-900 min-w-32 ${index() < class={`px-6 py-4 text-left text-sm font-semibold text-neutral-900 min-w-32 ${
l().SchemaItems index() <
.length - s().SchemaItems.length -
1 1
? "border-r border-neutral-200" ? "border-r border-neutral-200"
: "" : ""
}`} }`}
> >
{item.Item} <div class="flex items-center gap-2">
{item.Item}
<DeleteStackItemButton
onDelete={() =>
onDeleteStackItem(
s().ID,
item.ID,
)
}
/>
</div>
</th> </th>
)} )}
</For> </For>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-neutral-200"> <tbody class="divide-y divide-neutral-200">
<For each={l().Images}> <For each={s().Images}>
{(image, rowIndex) => ( {(image, rowIndex) => (
<tr <tr
class={`hover:bg-neutral-50 transition-colors ${rowIndex() % 2 === 0 class={`hover:bg-neutral-50 transition-colors ${
? "bg-white" rowIndex() % 2 === 0
: "bg-neutral-25" ? "bg-white"
}`} : "bg-neutral-25"
}`}
> >
<td class="px-6 py-4 border-r border-neutral-200"> <td class="px-6 py-4 border-r border-neutral-200">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@ -183,13 +243,13 @@ export const List: Component = () => {
<img <img
class="w-full h-full object-cover rounded-lg" class="w-full h-full object-cover rounded-lg"
src={`${base}/images/${image.ImageID}?token=${accessToken()}`} src={`${base}/images/${image.ImageID}?token=${accessToken()}`}
alt="List item" alt="Stack item"
/> />
</a> </a>
<DeleteButton <DeleteButton
onDelete={() => onDelete={() =>
onDeleteImageFromStack( onDeleteImageFromStack(
l().ID, s().ID,
image.ImageID, image.ImageID,
) )
} }
@ -199,13 +259,14 @@ export const List: Component = () => {
<For each={image.Items}> <For each={image.Items}>
{(item, colIndex) => ( {(item, colIndex) => (
<td <td
class={`px-6 py-4 text-sm text-neutral-700 ${colIndex() < class={`px-6 py-4 text-sm text-neutral-700 ${
colIndex() <
image.Items image.Items
.length - .length -
1 1
? "border-r border-neutral-200" ? "border-r border-neutral-200"
: "" : ""
}`} }`}
> >
<div <div
class="max-w-xs truncate" class="max-w-xs truncate"
@ -223,7 +284,7 @@ export const List: Component = () => {
</For> </For>
</tbody> </tbody>
</table> </table>
<Show when={l().Images.length === 0}> <Show when={s().Images.length === 0}>
<div class="px-6 py-12 text-center text-neutral-500"> <div class="px-6 py-12 text-center text-neutral-500">
<p class="text-lg"> <p class="text-lg">
No images in this list yet No images in this list yet

View File

@ -24,6 +24,7 @@ class SharedToken: Plugin {
} }
sharedDefaults.set(token, forKey: sharedTokenKey) sharedDefaults.set(token, forKey: sharedTokenKey)
sharedDefaults.synchronize()
invoke.resolve() invoke.resolve()
} }
} }