1 Commits

Author SHA1 Message Date
ece8536b72 feat: creating list processor 2025-10-05 15:02:12 +01:00
28 changed files with 632 additions and 421 deletions

View File

@ -7,7 +7,6 @@ import (
"screenmark/screenmark/.gen/haystack/haystack/model"
"screenmark/screenmark/agents/client"
"screenmark/screenmark/models"
"strings"
"github.com/charmbracelet/log"
"github.com/google/uuid"
@ -23,7 +22,7 @@ and add a good description for each one.
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 must respond in json format, do not add backticks to the json. ONLY valid json.
You must respond in json format
`
const listJsonSchema = `
@ -87,7 +86,7 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, stac
Model: "policy/images",
Temperature: 0.3,
ResponseFormat: client.ResponseFormat{
Type: "json_schema",
Type: "json_object",
JsonSchema: listJsonSchema,
},
Chat: &client.Chat{
@ -108,16 +107,12 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, stac
ctx := context.Background()
content := resp.Choices[0].Message.Content
structuredOutput := resp.Choices[0].Message.Content
if strings.HasPrefix(content, "```json") {
content = content[len("```json") : len(content)-3]
}
log.Info("", "res", content)
log.Info("", "res", structuredOutput)
var createListArgs createNewListArguments
err = json.Unmarshal([]byte(content), &createListArgs)
err = json.Unmarshal([]byte(structuredOutput), &createListArgs)
if err != nil {
return err
}

View File

@ -87,7 +87,6 @@ func (h *AuthHandler) code(body codeBody, 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)
if err != nil {
middleware.WriteErrorBadRequest(h.logger, "invalid refresh token: "+err.Error(), w)

View File

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

View File

@ -40,19 +40,6 @@ 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) {
descriptionSubLogger := p.logger.With("describe image", image.ID)
@ -110,8 +97,6 @@ func (p *ImageProcessor) processImage(image model.Image) {
wg.Wait()
p.setImageToDone(ctx, image)
// TODO: there is some repeated code here. The ergonomicts of the notifications,
// isn't the best.
imageNotification = notifications.GetImageNotification(notifications.ImageNotification{

View File

@ -45,19 +45,6 @@ func (p *StackProcessor) setStackToProcess(ctx context.Context, stack model.Stac
}
}
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 {
@ -100,8 +87,6 @@ func (p *StackProcessor) processImage(stack model.Stacks) {
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{

View File

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

View File

@ -53,7 +53,7 @@ func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) {
return
}
stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
stackID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
if err != nil {
return
}
@ -86,7 +86,7 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
return
}
stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
stackID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
if err != nil {
return
}

View File

@ -0,0 +1,24 @@
<?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

@ -0,0 +1,10 @@
<?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

@ -0,0 +1,39 @@
<?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

@ -0,0 +1,171 @@
//
//  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,189 +1,280 @@
//
//  ShareViewController.swift
//  Haystack
// ShareViewController.swift
// Haystack
//
//  Created by Rio Keefe on 03/05/2025.
// Created by Rio Keefe on 03/05/2025.
//
import UIKit
import Social
import MobileCoreServices
import MobileCoreServices // For kUTTypeImage
class ShareViewController: SLComposeServiceViewController {
let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier
let tokenKey = "sharedAuthToken" // This key holds the refresh token.
let uploadURL = URL(string: "https://haystack.johncosta.tech/images/")!
let tokenKey = "sharedAuthToken"
let uploadURLBase = URL(string: "https://haystack.johncosta.tech/image/")! // Base URL
var refreshToken: String?
var bearerToken: String?
private var imageItemProvider: NSItemProvider?
private var extractedImageName: String = "image" // Default name
// Store a base name, extension will be determined during item loading
private var baseImageName: String = "SharedImage" // A more descriptive default
override func viewDidLoad() {
super.viewDidLoad()
if let sharedDefaults = UserDefaults(suiteName: appGroupName) {
refreshToken = sharedDefaults.string(forKey: tokenKey)
print("Retrieved refresh token: \(refreshToken ?? "nil")")
bearerToken = sharedDefaults.string(forKey: tokenKey)
print("Retrieved bearer token: \(bearerToken ?? "nil")")
} else {
print("Error accessing App Group UserDefaults.")
// Invalidate content if token is crucial and missing
// This will be caught by isContentValid()
}
// 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])
}
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
let provider = extensionItem.attachments?.first else {
print("No attachments found.")
// Invalidate content if no provider
self.imageItemProvider = nil // Ensure it's nil so isContentValid fails
return
}
} 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
if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
self.imageItemProvider = provider
// Attempt to get a suggested name early if available, and clean it.
// This will be our default base name if the item itself doesn't provide a better one.
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 {
// Content is valid only if we have an item provider for an image AND a refresh token
return imageItemProvider != nil && refreshToken != nil
// Content is valid only if we have an item provider for an image AND a bearer token
let isValid = imageItemProvider != nil && bearerToken != nil
if imageItemProvider == nil {
print("isContentValid: imageItemProvider is nil")
}
if bearerToken == nil {
print("isContentValid: bearerToken is nil")
}
return isValid
}
override func didSelectPost() {
refreshToken { accessToken in
guard let token = accessToken else {
// Inform the user about the authentication failure
let error = NSError(domain: "ShareExtension", code: -1, userInfo: [NSLocalizedDescriptionKey: "Authentication failed. Please log in again."])
self.extensionContext!.cancelRequest(withError: error)
return
}
guard let provider = self.imageItemProvider else {
print("Error: No image item provider found when posting.")
// Inform the user or log an error
self.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)
}
}
}
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()
}
func refreshToken(completion: @escaping (String?) -> Void) {
guard let refreshToken = self.refreshToken else {
completion(nil)
guard let provider = imageItemProvider else {
print("Error: No image item provider found when posting.")
informUserAndCancel(message: "No image found to share.")
return
}
let url = URL(string: "https://haystack.johncosta.tech/auth/refresh")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let body = ["refresh": refreshToken]
request.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
if let data = data,
let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let accessToken = json["access"] as? String {
completion(accessToken)
} else {
completion(nil)
}
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
}
var imageData: Data?
var finalImageNameWithExtension: String
var mimeType: String = "application/octet-stream" // Default MIME type
// 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
}
guard let dataToUpload = imageData else {
print("Error: No image data to upload after processing.")
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 }
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 {
print("Upload failed: \(error.localizedDescription)")
self.informUserAndCancel(message: "Upload failed: \(error.localizedDescription)")
} else if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) {
let errorDescription = "Upload failed. Server returned status code \(httpResponse.statusCode)."
print(errorDescription)
self.informUserAndCancel(message: errorDescription)
} else {
print("Upload successful for \(imageNameWithExtension)")
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 []
override func configurationItems() -> [Any]! {
// No configuration items needed for this simple image uploader.
return []
}
// Helper to inform user and cancel request
private func informUserAndCancel(message: String) {
let error = NSError(domain: "com.haystack.ShareExtension", code: 0, userInfo: [NSLocalizedDescriptionKey: message])
print("Informing user: \(message)")
// 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
private func mimeType(forPathExtension pathExtension: String) -> String {
let uti = UTTypeCreatePreferredIdentifierForTag(kUTTagClassFilenameExtension, pathExtension as CFString, nil)?.takeRetainedValue()
if let uti = uti {
let mimeType = UTTypeCopyPreferredTagWithClass(uti, kUTTagClassMIMEType)?.takeRetainedValue()
if let mimeType = mimeType {
return mimeType as String
}
}
// Fallback for common types if UTType fails or for robustness
switch pathExtension.lowercased() {
// case "jpg", "jpeg": return "image/jpeg"
// case "png": return "image/png"
// case "gif": return "image/gif"
// case "bmp": return "image/bmp"
// case "tiff", "tif": return "image/tiff"
default: return "application/octet-stream" // Generic fallback
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import { Navigate } from "@solidjs/router";
import { platform } from "@tauri-apps/plugin-os";
import { jwtDecode } from "jwt-decode";
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";
export const isTokenValid = (): boolean => {
@ -34,10 +36,19 @@ export const ProtectedRoute: Component<ParentProps> = (props) => {
const isValid = isTokenValid();
if (isValid) {
const token = localStorage.getItem("refresh");
const token = localStorage.getItem("access");
if (token == null) {
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 (

View File

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

View File

@ -10,7 +10,6 @@ import {
import {
deleteImage,
deleteImageFromStack,
deleteStack,
deleteStackItem,
getUserImages,
JustTheImageWhatAreTheseNames,
@ -26,11 +25,8 @@ export type SearchImageStore = {
userImages: Accessor<JustTheImageWhatAreTheseNames>;
onRefetchImages: () => void;
onDeleteImage: (imageID: string) => void;
onDeleteImageFromStack: (stackID: string, imageID: string) => void;
onDeleteStack: (stackID: string) => void;
onDeleteStackItem: (stackID: string, schemaItemID: string) => void;
};
@ -80,9 +76,6 @@ export const SearchImageContextProvider: Component<ParentProps> = (props) => {
onDeleteImageFromStack: (stackID: string, imageID: string) => {
deleteImageFromStack(stackID, imageID).then(refetch);
},
onDeleteStack: (stackID: string) => {
deleteStack(stackID).then(refetch)
},
onDeleteStackItem: (stackID: string, schemaItemID: string) => {
deleteStackItem(stackID, schemaItemID).then(refetch);
},

View File

@ -1,7 +1,5 @@
import { getTokenProperties } from "@components/protected-route";
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 {
type InferOutput,
@ -25,8 +23,8 @@ type BaseRequestParams = Partial<{
method: "GET" | "POST" | "DELETE";
}>;
export const base = "https://haystack.johncosta.tech";
// export const base = "http://192.168.1.199:3040";
// export const base = "https://haystack.johncosta.tech";
export const base = "http://localhost:3040";
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
return new Request(`${base}/${path}`, {
@ -44,53 +42,25 @@ export const getAccessToken = async (): Promise<string> => {
const refreshToken = localStorage.getItem("refresh")?.toString();
if (accessToken == null && refreshToken == null) {
throw new Error("you are not logged in")
throw new Error("your are not logged in")
}
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'
});
const isValidAccessToken = accessToken != null && getTokenProperties(accessToken).exp.getTime() * 1000 > Date.now()
if (!isValidAccessToken) {
console.log('Refreshing token...');
const newAccessToken = await fetch(getBaseRequest({
path: 'auth/refresh',
method: "POST",
body: JSON.stringify({
path: 'auth/refresh', method: "POST", body: JSON.stringify({
refresh: refreshToken,
})
})).then(r => r.json());
const { access } = parse(refreshTokenValidator, newAccessToken);
localStorage.setItem("access", access);
accessToken = access;
accessToken = access
}
return accessToken!;
return accessToken!
}
const getBaseAuthorizedRequest = async ({
@ -163,7 +133,7 @@ export const deleteStackItem = async (
await fetch(request);
}
export const deleteStack = async (listID: string): Promise<void> => {
export const deleteList = async (listID: string): Promise<void> => {
const request = await getBaseAuthorizedRequest({
path: `stacks/${listID}`,
method: "DELETE",
@ -224,7 +194,7 @@ const userImageValidator = strictObject({
strictObject({
ID: pipe(string(), uuid()),
ImageID: pipe(string(), uuid()),
StackID: pipe(string(), uuid()),
ListID: pipe(string(), uuid()),
}),
)), transform(l => l ?? [])),
});
@ -268,7 +238,7 @@ const stackValidator = strictObject({
SchemaItems: array(stackSchemaItem),
});
export type Stack = InferOutput<typeof stackValidator>;
export type List = InferOutput<typeof stackValidator>;
const imageRequestValidator = strictObject({
UserImages: array(userImageValidator),
@ -333,13 +303,13 @@ export const postCode = async (
return parsedRes.output;
};
export class ReachedStackLimit extends Error {
export class ReachedListLimit extends Error {
constructor() {
super();
}
}
export const createStack = async (
export const createList = async (
title: string,
description: string,
): Promise<void> => {
@ -353,6 +323,6 @@ export const createStack = async (
const res = await fetch(request);
if (!res.ok && res.status == 429) {
throw new ReachedStackLimit();
throw new ReachedListLimit();
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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