Compare commits
15 Commits
38bda46dcf
...
main
Author | SHA1 | Date | |
---|---|---|---|
106d3b1fa1 | |||
b9f6b77286 | |||
3c8fd843e6 | |||
e61af3007f | |||
3594baceb5 | |||
d534779fad | |||
a776c88cab | |||
72de7c7648 | |||
a8b150857c | |||
dd4f508346 | |||
f21ee57632 | |||
0e42c9002b | |||
9e60a41f0a | |||
eaff553dc9 | |||
6880811236 |
@ -7,6 +7,7 @@ import (
|
||||
"screenmark/screenmark/.gen/haystack/haystack/model"
|
||||
"screenmark/screenmark/agents/client"
|
||||
"screenmark/screenmark/models"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/google/uuid"
|
||||
@ -109,12 +110,14 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, stac
|
||||
|
||||
content := resp.Choices[0].Message.Content
|
||||
|
||||
structuredOutput := content[len("```json") : len(content)-3]
|
||||
if strings.HasPrefix(content, "```json") {
|
||||
content = content[len("```json") : len(content)-3]
|
||||
}
|
||||
|
||||
log.Info("", "res", structuredOutput)
|
||||
log.Info("", "res", content)
|
||||
|
||||
var createListArgs createNewListArguments
|
||||
err = json.Unmarshal([]byte(structuredOutput), &createListArgs)
|
||||
err = json.Unmarshal([]byte(content), &createListArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -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) {
|
||||
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)
|
||||
|
@ -60,7 +60,7 @@ func (m UserModel) GetUserImages(ctx context.Context, userId uuid.UUID) ([]UserI
|
||||
).
|
||||
FROM(
|
||||
Image.
|
||||
LEFT_JOIN(ImageStacks, ImageStacks.ImageID.EQ(ImageStacks.ID)),
|
||||
LEFT_JOIN(ImageStacks, ImageStacks.ImageID.EQ(Image.ID)),
|
||||
).
|
||||
WHERE(Image.UserID.EQ(UUID(userId)))
|
||||
|
||||
|
@ -45,7 +45,9 @@ 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
|
||||
stack_id UUID NOT NULL REFERENCES haystack.stacks (id) ON DELETE CASCADE,
|
||||
|
||||
UNIQUE(image_id, stack_id)
|
||||
);
|
||||
|
||||
CREATE TABLE haystack.schema_items (
|
||||
|
Binary file not shown.
@ -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>
|
@ -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>
|
@ -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>
|
@ -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 []
|
||||
}
|
||||
}
|
@ -1,280 +1,189 @@
|
||||
//
|
||||
// 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 // For kUTTypeImage
|
||||
import MobileCoreServices
|
||||
|
||||
class ShareViewController: SLComposeServiceViewController {
|
||||
|
||||
let appGroupName = "group.com.haystack.app" // Replace with your actual App Group identifier
|
||||
let tokenKey = "sharedAuthToken"
|
||||
let uploadURLBase = URL(string: "https://haystack.johncosta.tech/image/")! // Base URL
|
||||
let tokenKey = "sharedAuthToken" // This key holds the refresh token.
|
||||
let uploadURL = URL(string: "https://haystack.johncosta.tech/images/")!
|
||||
|
||||
var bearerToken: String?
|
||||
var refreshToken: String?
|
||||
private var imageItemProvider: NSItemProvider?
|
||||
// Store a base name, extension will be determined during item loading
|
||||
private var baseImageName: String = "SharedImage" // A more descriptive default
|
||||
private var extractedImageName: String = "image" // Default name
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
if let sharedDefaults = UserDefaults(suiteName: appGroupName) {
|
||||
bearerToken = sharedDefaults.string(forKey: tokenKey)
|
||||
print("Retrieved bearer token: \(bearerToken ?? "nil")")
|
||||
refreshToken = sharedDefaults.string(forKey: tokenKey)
|
||||
print("Retrieved refresh token: \(refreshToken ?? "nil")")
|
||||
} else {
|
||||
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,
|
||||
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
|
||||
}
|
||||
// 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])
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
} 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
|
||||
}
|
||||
// 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 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
|
||||
// Content is valid only if we have an item provider for an image AND a refresh token
|
||||
return imageItemProvider != nil && refreshToken != nil
|
||||
}
|
||||
|
||||
override func didSelectPost() {
|
||||
guard let provider = imageItemProvider else {
|
||||
print("Error: No image item provider found when posting.")
|
||||
informUserAndCancel(message: "No image found to share.")
|
||||
return
|
||||
}
|
||||
|
||||
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)")
|
||||
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
|
||||
}
|
||||
|
||||
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.")
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
// Load the image data asynchronously
|
||||
provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil) { [weak self] (item, error) in
|
||||
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)
|
||||
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("Upload successful for \(imageNameWithExtension)")
|
||||
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
|
||||
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()
|
||||
task.resume()
|
||||
}
|
||||
|
||||
override func configurationItems() -> [Any]! {
|
||||
// No configuration items needed for this simple image uploader.
|
||||
return []
|
||||
}
|
||||
func refreshToken(completion: @escaping (String?) -> Void) {
|
||||
guard let refreshToken = self.refreshToken else {
|
||||
completion(nil)
|
||||
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)
|
||||
}
|
||||
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")
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,6 @@
|
||||
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 => {
|
||||
@ -36,19 +34,10 @@ export const ProtectedRoute: Component<ParentProps> = (props) => {
|
||||
const isValid = isTokenValid();
|
||||
|
||||
if (isValid) {
|
||||
const token = localStorage.getItem("access");
|
||||
const token = localStorage.getItem("refresh");
|
||||
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 (
|
||||
|
@ -39,8 +39,6 @@ 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;
|
||||
@ -100,7 +98,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",
|
||||
|
@ -1,5 +1,7 @@
|
||||
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,
|
||||
@ -23,8 +25,8 @@ type BaseRequestParams = Partial<{
|
||||
method: "GET" | "POST" | "DELETE";
|
||||
}>;
|
||||
|
||||
// export const base = "https://haystack.johncosta.tech";
|
||||
export const base = "http://localhost:3040";
|
||||
export const base = "https://haystack.johncosta.tech";
|
||||
// export const base = "http://192.168.1.199:3040";
|
||||
|
||||
const getBaseRequest = ({ path, body, method }: BaseRequestParams): Request => {
|
||||
return new Request(`${base}/${path}`, {
|
||||
@ -42,25 +44,53 @@ export const getAccessToken = async (): Promise<string> => {
|
||||
const refreshToken = localStorage.getItem("refresh")?.toString();
|
||||
|
||||
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) {
|
||||
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 ({
|
||||
@ -194,7 +224,7 @@ const userImageValidator = strictObject({
|
||||
strictObject({
|
||||
ID: pipe(string(), uuid()),
|
||||
ImageID: pipe(string(), uuid()),
|
||||
ListID: pipe(string(), uuid()),
|
||||
StackID: pipe(string(), uuid()),
|
||||
}),
|
||||
)), transform(l => l ?? [])),
|
||||
});
|
||||
|
@ -39,7 +39,7 @@ 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-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()}>{(stack) => <StackCard stack={stack} />}</For>
|
||||
</div>
|
||||
|
||||
|
@ -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-3 gap-4 place-items-center">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 place-items-center">
|
||||
<For each={latestImages()}>
|
||||
{(image) => <ImageComponent ID={image.ID} onDelete={onDeleteImage} />}
|
||||
</For>
|
||||
|
@ -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">Description</h2>
|
||||
<h2 class="font-bold text-2xl">Stacks</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<For each={image()?.ImageStacks}>
|
||||
{(imageList) => (
|
||||
<StackCard
|
||||
stack={stacks().find((l) => l.ID === imageList.ListID)!}
|
||||
stack={stacks().find((l) => l.ID === imageList.StackID)!}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
@ -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: ["Image.Description"],
|
||||
});
|
||||
return () =>
|
||||
new Fuse(userImages(), {
|
||||
shouldSort: true,
|
||||
keys: ["Description"],
|
||||
});
|
||||
};
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||
import { useParams, useNavigate } from "@solidjs/router";
|
||||
import {
|
||||
Component,
|
||||
@ -10,6 +9,7 @@ import {
|
||||
} from "solid-js";
|
||||
import { base, getAccessToken } from "../../network";
|
||||
import { Dialog } from "@kobalte/core";
|
||||
import { useSearchImageContext } from "@contexts/SearchImageContext";
|
||||
|
||||
const DeleteButton: Component<{ onDelete: () => void }> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
@ -103,11 +103,58 @@ 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();
|
||||
const nav = useNavigate();
|
||||
|
||||
const { stacks, onDeleteImageFromStack, onDeleteStack } = useSearchImageContext();
|
||||
const { stacks, onDeleteImageFromStack, onDeleteStack, onDeleteStackItem } =
|
||||
useSearchImageContext();
|
||||
|
||||
const [accessToken] = createResource(getAccessToken);
|
||||
|
||||
@ -135,7 +182,9 @@ export const Stack: Component = () => {
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<DeleteStackButton onDelete={handleDeleteStack} />
|
||||
<DeleteStackButton
|
||||
onDelete={handleDeleteStack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -151,15 +200,25 @@ export const Stack: Component = () => {
|
||||
<For each={s().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() <
|
||||
s().SchemaItems.length -
|
||||
1
|
||||
? "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>
|
||||
)}
|
||||
</For>
|
||||
@ -169,10 +228,11 @@ export const Stack: Component = () => {
|
||||
<For each={s().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">
|
||||
@ -199,13 +259,14 @@ 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"
|
||||
|
@ -24,6 +24,7 @@ class SharedToken: Plugin {
|
||||
}
|
||||
|
||||
sharedDefaults.set(token, forKey: sharedTokenKey)
|
||||
sharedDefaults.synchronize()
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user