Compare commits
19 Commits
ece8536b72
...
main
Author | SHA1 | Date | |
---|---|---|---|
106d3b1fa1 | |||
b9f6b77286 | |||
3c8fd843e6 | |||
e61af3007f | |||
3594baceb5 | |||
d534779fad | |||
a776c88cab | |||
72de7c7648 | |||
a8b150857c | |||
dd4f508346 | |||
f21ee57632 | |||
0e42c9002b | |||
9e60a41f0a | |||
eaff553dc9 | |||
6880811236 | |||
38bda46dcf | |||
bd86ad499b | |||
838ab37fc1 | |||
9948d2521b |
@ -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"
|
||||
@ -21,6 +22,8 @@ and add a good description for each one.
|
||||
|
||||
You can add fields if you think they make a lot of sense.
|
||||
You can remove fields if they are not correct, but be sure before you do this.
|
||||
|
||||
You must respond in json format, do not add backticks to the json. ONLY valid json.
|
||||
`
|
||||
|
||||
const listJsonSchema = `
|
||||
@ -79,12 +82,12 @@ type CreateListAgent struct {
|
||||
stackModel models.StackModel
|
||||
}
|
||||
|
||||
func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, title string, userReq string) error {
|
||||
func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, stackID uuid.UUID, title string, userReq string) error {
|
||||
request := client.AgentRequestBody{
|
||||
Model: "policy/images",
|
||||
Temperature: 0.3,
|
||||
ResponseFormat: client.ResponseFormat{
|
||||
Type: "json_object",
|
||||
Type: "json_schema",
|
||||
JsonSchema: listJsonSchema,
|
||||
},
|
||||
Chat: &client.Chat{
|
||||
@ -105,10 +108,16 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, titl
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
structuredOutput := resp.Choices[0].Message.Content
|
||||
content := resp.Choices[0].Message.Content
|
||||
|
||||
if strings.HasPrefix(content, "```json") {
|
||||
content = content[len("```json") : len(content)-3]
|
||||
}
|
||||
|
||||
log.Info("", "res", content)
|
||||
|
||||
var createListArgs createNewListArguments
|
||||
err = json.Unmarshal([]byte(structuredOutput), &createListArgs)
|
||||
err = json.Unmarshal([]byte(content), &createListArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -116,6 +125,8 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, titl
|
||||
schemaItems := make([]model.SchemaItems, 0)
|
||||
for _, field := range createListArgs.Fields {
|
||||
schemaItems = append(schemaItems, model.SchemaItems{
|
||||
StackID: stackID,
|
||||
|
||||
Item: field.Name,
|
||||
Description: field.Description,
|
||||
|
||||
@ -123,11 +134,6 @@ func (agent *CreateListAgent) CreateList(log *log.Logger, userID uuid.UUID, titl
|
||||
})
|
||||
}
|
||||
|
||||
_, err = agent.stackModel.Save(ctx, userID, createListArgs.Title, createListArgs.Description, model.Progress_Complete)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating list agent, saving list: %w", err)
|
||||
}
|
||||
|
||||
err = agent.stackModel.SaveItems(ctx, schemaItems)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating list agent, saving items: %w", 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)))
|
||||
|
||||
|
@ -9,7 +9,7 @@ import (
|
||||
|
||||
const (
|
||||
IMAGE_TYPE = "image"
|
||||
STACK_TYPE = "list"
|
||||
STACK_TYPE = "stack"
|
||||
)
|
||||
|
||||
type ImageNotification struct {
|
@ -40,6 +40,19 @@ func (p *ImageProcessor) setImageToProcess(ctx context.Context, image model.Imag
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ImageProcessor) setImageToDone(ctx context.Context, image model.Image) {
|
||||
err := p.imageModel.UpdateProcess(ctx, image.ID, model.Progress_Complete)
|
||||
if err != nil {
|
||||
// TODO: what can we actually do here for the errors?
|
||||
// We can't stop the work for the others
|
||||
|
||||
p.logger.Error("failed to update image", "err", err)
|
||||
|
||||
// TODO: we can use context here to actually pass some information through
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ImageProcessor) describe(ctx context.Context, image model.Image) {
|
||||
descriptionSubLogger := p.logger.With("describe image", image.ID)
|
||||
|
||||
@ -97,6 +110,8 @@ 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{
|
||||
|
@ -45,8 +45,21 @@ 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.Name, stack.Description)
|
||||
err := p.stackAgent.CreateList(p.logger, stack.UserID, stack.ID, stack.Name, stack.Description)
|
||||
if err != nil {
|
||||
// Again, wtf do we do?
|
||||
// Although i think the agent actually returns an error when it's finished
|
||||
@ -87,6 +100,8 @@ 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{
|
||||
|
@ -41,7 +41,7 @@ func setupRouter(db *sql.DB, jwtManager *ourmiddleware.JwtManager) (chi.Router,
|
||||
return nil, fmt.Errorf("processor: %w", err)
|
||||
}
|
||||
|
||||
stackProcessorLog := createLogger("Stack0 Processor", os.Stdout)
|
||||
stackProcessorLog := createLogger("Stack Processor", os.Stdout)
|
||||
stackProcessor, err := processor.NewStackProcessor(stackProcessorLog, stackModel, ¬ifier)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processor: %w", err)
|
||||
@ -50,7 +50,7 @@ func setupRouter(db *sql.DB, jwtManager *ourmiddleware.JwtManager) (chi.Router,
|
||||
go imageProcessor.Processor.Work()
|
||||
go stackProcessor.Processor.Work()
|
||||
|
||||
stackHandler := stacks.CreateStackHandler(db, limitsManager, jwtManager)
|
||||
stackHandler := stacks.CreateStackHandler(db, limitsManager, jwtManager, stackProcessor.Processor)
|
||||
authHandler := auth.CreateAuthHandler(db, jwtManager)
|
||||
imageHandler := images.CreateImageHandler(db, limitsManager, jwtManager, imageProcessor.Processor)
|
||||
|
||||
|
@ -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 (
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
"screenmark/screenmark/limits"
|
||||
"screenmark/screenmark/middleware"
|
||||
"screenmark/screenmark/models"
|
||||
"screenmark/screenmark/processor"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/go-chi/chi/v5"
|
||||
@ -23,6 +24,8 @@ type StackHandler struct {
|
||||
limitsManager limits.LimitsManagerMethods
|
||||
|
||||
jwtManager *middleware.JwtManager
|
||||
|
||||
processor *processor.Processor[model.Stacks]
|
||||
}
|
||||
|
||||
func (h *StackHandler) getAllStacks(w http.ResponseWriter, r *http.Request) {
|
||||
@ -50,7 +53,7 @@ func (h *StackHandler) getStackItems(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stackID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
|
||||
stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -83,7 +86,7 @@ func (h *StackHandler) deleteStack(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
stackID, err := middleware.GetPathParamID(h.logger, "listID", w, r)
|
||||
stackID, err := middleware.GetPathParamID(h.logger, "stackID", w, r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@ -216,6 +219,8 @@ func (h *StackHandler) createStack(body CreateStackBody, w http.ResponseWriter,
|
||||
return
|
||||
}
|
||||
|
||||
h.processor.Add(stack)
|
||||
|
||||
middleware.WriteJsonOrError(h.logger, stack, w)
|
||||
}
|
||||
|
||||
@ -237,7 +242,12 @@ func (h *StackHandler) CreateRoutes(r chi.Router) {
|
||||
})
|
||||
}
|
||||
|
||||
func CreateStackHandler(db *sql.DB, limitsManager limits.LimitsManagerMethods, jwtManager *middleware.JwtManager) StackHandler {
|
||||
func CreateStackHandler(
|
||||
db *sql.DB,
|
||||
limitsManager limits.LimitsManagerMethods,
|
||||
jwtManager *middleware.JwtManager,
|
||||
processor *processor.Processor[model.Stacks],
|
||||
) StackHandler {
|
||||
stackModel := models.NewStackModel(db)
|
||||
imageModel := models.NewImageModel(db)
|
||||
logger := log.New(os.Stdout).WithPrefix("Stacks")
|
||||
@ -248,5 +258,6 @@ func CreateStackHandler(db *sql.DB, limitsManager limits.LimitsManagerMethods, j
|
||||
stackModel: stackModel,
|
||||
limitsManager: limitsManager,
|
||||
jwtManager: jwtManager,
|
||||
processor: processor,
|
||||
}
|
||||
}
|
||||
|
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 []
|
||||
}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
Settings,
|
||||
SearchPage,
|
||||
AllImages,
|
||||
List,
|
||||
Stack,
|
||||
} 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="/list/:listId" component={List} />
|
||||
<Route path="/stack/:stackID" component={Stack} />
|
||||
<Route path="/settings" component={Settings} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
@ -9,7 +9,7 @@ export const ProcessingImages: Component = () => {
|
||||
|
||||
const processingNumber = () =>
|
||||
Object.keys(notifications.state.ProcessingImages).length +
|
||||
Object.keys(notifications.state.ProcessingLists).length;
|
||||
Object.keys(notifications.state.ProcessingStacks).length;
|
||||
|
||||
const [accessToken] = createResource(getAccessToken)
|
||||
|
||||
@ -60,13 +60,13 @@ export const ProcessingImages: Component = () => {
|
||||
)}
|
||||
</For>
|
||||
|
||||
<For each={Object.entries(notifications.state.ProcessingLists)}>
|
||||
{([, _list]) => (
|
||||
<Show when={_list}>
|
||||
{(list) => (
|
||||
<For each={Object.entries(notifications.state.ProcessingStacks)}>
|
||||
{([, _stack]) => (
|
||||
<Show when={_stack}>
|
||||
{(stack) => (
|
||||
<div class="flex gap-2 w-full justify-center">
|
||||
<div class="flex flex-col gap-1">
|
||||
<p class="text-slate-900">New Stack: {list().Name}</p>
|
||||
<p class="text-slate-900">New Stack: {stack().Name}</p>
|
||||
</div>
|
||||
<LoadingCircle
|
||||
status="loading"
|
||||
|
@ -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 (
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { List } from "@network/index";
|
||||
import { Stack } 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<{ list: List }> = (props) => {
|
||||
export const StackCard: Component<{ stack: Stack }> = (props) => {
|
||||
return (
|
||||
<A
|
||||
href={`/list/${props.list.ID}`}
|
||||
href={`/stack/${props.stack.ID}`}
|
||||
class={
|
||||
"flex flex-col p-4 border border-neutral-200 rounded-lg " +
|
||||
colors[
|
||||
fastHashCode(props.list.Name, { forcePositive: true }) % colors.length
|
||||
fastHashCode(props.stack.Name, { forcePositive: true }) % colors.length
|
||||
]
|
||||
}
|
||||
>
|
||||
<p class="text-xl font-bold">{props.list.Name}</p>
|
||||
<p class="text-lg">{props.list.Images.length}</p>
|
||||
<p class="text-xl font-bold">{props.stack.Name}</p>
|
||||
<p class="text-lg">{props.stack.Images.length}</p>
|
||||
</A>
|
||||
);
|
||||
};
|
@ -22,7 +22,7 @@ type NotificationState = {
|
||||
string,
|
||||
InferOutput<typeof processingImagesValidator> | undefined
|
||||
>;
|
||||
ProcessingLists: Record<
|
||||
ProcessingStacks: Record<
|
||||
string,
|
||||
InferOutput<typeof processingListValidator> | undefined
|
||||
>;
|
||||
@ -31,7 +31,7 @@ type NotificationState = {
|
||||
export const Notifications = (onCompleteImage: () => void) => {
|
||||
const [state, setState] = createStore<NotificationState>({
|
||||
ProcessingImages: {},
|
||||
ProcessingLists: {},
|
||||
ProcessingStacks: {},
|
||||
});
|
||||
|
||||
const { userImages } = useSearchImageContext();
|
||||
@ -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;
|
||||
@ -71,14 +69,14 @@ export const Notifications = (onCompleteImage: () => void) => {
|
||||
} else {
|
||||
setState("ProcessingImages", ImageID, notification.output);
|
||||
}
|
||||
} else if (notification.output.Type === "list") {
|
||||
const { ListID, Status } = notification.output;
|
||||
} else if (notification.output.Type === "stack") {
|
||||
const { StackID, Status } = notification.output;
|
||||
|
||||
if (Status === "complete") {
|
||||
setState("ProcessingLists", ListID, undefined);
|
||||
setState("ProcessingStacks", StackID, undefined);
|
||||
onCompleteImage();
|
||||
} else {
|
||||
setState("ProcessingLists", ListID, notification.output);
|
||||
setState("ProcessingStacks", StackID, notification.output);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -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",
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
import {
|
||||
deleteImage,
|
||||
deleteImageFromStack,
|
||||
deleteStack,
|
||||
deleteStackItem,
|
||||
getUserImages,
|
||||
JustTheImageWhatAreTheseNames,
|
||||
@ -25,8 +26,11 @@ 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;
|
||||
};
|
||||
|
||||
@ -76,6 +80,9 @@ 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);
|
||||
},
|
||||
|
@ -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 ({
|
||||
@ -133,7 +163,7 @@ export const deleteStackItem = async (
|
||||
await fetch(request);
|
||||
}
|
||||
|
||||
export const deleteList = async (listID: string): Promise<void> => {
|
||||
export const deleteStack = async (listID: string): Promise<void> => {
|
||||
const request = await getBaseAuthorizedRequest({
|
||||
path: `stacks/${listID}`,
|
||||
method: "DELETE",
|
||||
@ -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 ?? [])),
|
||||
});
|
||||
@ -238,7 +268,7 @@ const stackValidator = strictObject({
|
||||
SchemaItems: array(stackSchemaItem),
|
||||
});
|
||||
|
||||
export type List = InferOutput<typeof stackValidator>;
|
||||
export type Stack = InferOutput<typeof stackValidator>;
|
||||
|
||||
const imageRequestValidator = strictObject({
|
||||
UserImages: array(userImageValidator),
|
||||
@ -303,13 +333,13 @@ export const postCode = async (
|
||||
return parsedRes.output;
|
||||
};
|
||||
|
||||
export class ReachedListLimit extends Error {
|
||||
export class ReachedStackLimit extends Error {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export const createList = async (
|
||||
export const createStack = async (
|
||||
title: string,
|
||||
description: string,
|
||||
): Promise<void> => {
|
||||
@ -323,6 +353,6 @@ export const createList = async (
|
||||
|
||||
const res = await fetch(request);
|
||||
if (!res.ok && res.status == 429) {
|
||||
throw new ReachedListLimit();
|
||||
throw new ReachedStackLimit();
|
||||
}
|
||||
};
|
||||
|
@ -1,31 +1,31 @@
|
||||
import { literal, pipe, strictObject, string, union, uuid } from "valibot";
|
||||
|
||||
export const processingListValidator = strictObject({
|
||||
Type: literal("list"),
|
||||
Type: literal("stack"),
|
||||
|
||||
Name: string(),
|
||||
ListID: pipe(string(), uuid()),
|
||||
Name: string(),
|
||||
StackID: 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,
|
||||
]);
|
||||
|
@ -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 { createList, ReachedListLimit } from "../../network";
|
||||
import { createStack, ReachedStackLimit } 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 handleCreateList = async () => {
|
||||
const handleCreatestack = async () => {
|
||||
if (description().trim().length === 0 || title().trim().length === 0)
|
||||
return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createList(title().trim(), description().trim());
|
||||
await createStack(title().trim(), description().trim());
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setShowForm(false);
|
||||
onRefetchImages(); // Refresh the stacks
|
||||
} catch (error) {
|
||||
console.error("Failed to create list:", error);
|
||||
if (error instanceof ReachedListLimit) {
|
||||
console.error("Failed to create stack:", error);
|
||||
if (error instanceof ReachedStackLimit) {
|
||||
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-3 auto-rows-[minmax(100px,1fr)] gap-4">
|
||||
<For each={stacks()}>{(list) => <StackCard list={list} />}</For>
|
||||
<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>
|
||||
|
||||
<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 List
|
||||
+ Create stack
|
||||
</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 List
|
||||
Create New stack
|
||||
</Dialog.Title>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="list-title"
|
||||
for="stack-title"
|
||||
class="block text-sm font-medium text-neutral-700 mb-2"
|
||||
>
|
||||
List Title
|
||||
stack Title
|
||||
</label>
|
||||
<input
|
||||
id="list-title"
|
||||
id="stack-title"
|
||||
type="text"
|
||||
value={title()}
|
||||
onInput={(e) =>
|
||||
setTitle(e.target.value)
|
||||
}
|
||||
placeholder="Enter a title for your list"
|
||||
placeholder="Enter a title for your stack"
|
||||
class="w-full p-3 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
|
||||
disabled={isCreating()}
|
||||
/>
|
||||
@ -85,18 +85,18 @@ export const Categories: Component = () => {
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="list-description"
|
||||
for="stack-description"
|
||||
class="block text-sm font-medium text-neutral-700 mb-2"
|
||||
>
|
||||
List Description
|
||||
stack Description
|
||||
</label>
|
||||
<textarea
|
||||
id="list-description"
|
||||
id="stack-description"
|
||||
value={description()}
|
||||
onInput={(e) =>
|
||||
setDescription(e.target.value)
|
||||
}
|
||||
placeholder="Describe what kind of list you want to create (e.g., 'A list of my favorite recipes' or 'Photos from my vacation')"
|
||||
placeholder="Describe what kind of stack you want to create (e.g., 'A list of my favorite recipes' or 'Photos from my vacation')"
|
||||
class="w-full p-3 border border-neutral-300 rounded-lg resize-none focus:ring-2 focus:ring-indigo-600 focus:border-transparent transition-colors"
|
||||
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={handleCreateList}
|
||||
onClick={handleCreatestack}
|
||||
disabled={
|
||||
isCreating() ||
|
||||
!title().trim() ||
|
||||
@ -116,7 +116,7 @@ export const Categories: Component = () => {
|
||||
>
|
||||
{isCreating()
|
||||
? "Creating..."
|
||||
: "Create List"}
|
||||
: "Create stack"}
|
||||
</Button>
|
||||
<Button
|
||||
class="px-4 py-2 bg-neutral-300 text-neutral-700 rounded-lg hover:bg-neutral-400 transition-colors font-medium"
|
||||
|
@ -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>
|
||||
|
@ -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">Description</h2>
|
||||
<h2 class="font-bold text-2xl">Stacks</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<For each={image()?.ImageStacks}>
|
||||
{(imageList) => (
|
||||
<StackCard
|
||||
list={stacks().find((l) => l.ID === imageList.ListID)!}
|
||||
stack={stacks().find((l) => l.ID === imageList.StackID)!}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
@ -4,4 +4,4 @@ export * from "./settings";
|
||||
export * from "./login";
|
||||
export * from "./search";
|
||||
export * from "./all-images";
|
||||
export * from "./list";
|
||||
export * from "./stack";
|
||||
|
@ -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,
|
||||
@ -8,8 +7,9 @@ import {
|
||||
createResource,
|
||||
createSignal,
|
||||
} from "solid-js";
|
||||
import { base, deleteList, getAccessToken } from "../../network";
|
||||
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);
|
||||
@ -57,7 +57,7 @@ const DeleteButton: Component<{ onDelete: () => void }> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
|
||||
const DeleteStackButton: Component<{ onDelete: () => void }> = (props) => {
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
|
||||
return (
|
||||
@ -67,7 +67,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
|
||||
class="text-white bg-red-600 hover:bg-red-700 rounded px-3 py-2 text-sm font-medium"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
Delete List
|
||||
Delete Stack
|
||||
</button>
|
||||
|
||||
<Dialog.Root open={isOpen()} onOpenChange={setIsOpen}>
|
||||
@ -76,7 +76,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<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 List
|
||||
Confirm Delete Stack
|
||||
</Dialog.Title>
|
||||
<Dialog.Description class="mb-4">
|
||||
Are you sure you want to delete this entire
|
||||
@ -92,7 +92,7 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
|
||||
class="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
Delete List
|
||||
Delete Stack
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
@ -103,39 +103,88 @@ const DeleteListButton: Component<{ onDelete: () => void }> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const List: Component = () => {
|
||||
const { listId } = useParams();
|
||||
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 } = useSearchImageContext();
|
||||
const { stacks, onDeleteImageFromStack, onDeleteStack, onDeleteStackItem } =
|
||||
useSearchImageContext();
|
||||
|
||||
const [accessToken] = createResource(getAccessToken);
|
||||
|
||||
const list = () => stacks().find((l) => l.ID === listId);
|
||||
const stack = () => stacks().find((l) => l.ID === stackID);
|
||||
|
||||
const handleDeleteList = async () => {
|
||||
await deleteList(listId)
|
||||
const handleDeleteStack = async () => {
|
||||
onDeleteStack(stackID);
|
||||
nav("/");
|
||||
};
|
||||
|
||||
return (
|
||||
<Suspense>
|
||||
<Show when={list()} fallback="List could not be found">
|
||||
{(l) => (
|
||||
<Show when={stack()} fallback="Stack could not be found">
|
||||
{(s) => (
|
||||
<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">
|
||||
{l().Name}
|
||||
{s().Name}
|
||||
</h1>
|
||||
<Show when={l().Description}>
|
||||
<Show when={s().Description}>
|
||||
<p class="text-sm text-neutral-600 mt-1">
|
||||
{l().Description}
|
||||
{s().Description}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<DeleteListButton onDelete={handleDeleteList} />
|
||||
<DeleteStackButton
|
||||
onDelete={handleDeleteStack}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@ -148,31 +197,42 @@ export const List: Component = () => {
|
||||
<th class="px-6 py-4 text-left text-sm font-semibold text-neutral-900 border-r border-neutral-200 min-w-40">
|
||||
Image
|
||||
</th>
|
||||
<For each={l().SchemaItems}>
|
||||
<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() <
|
||||
l().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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-200">
|
||||
<For each={l().Images}>
|
||||
<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">
|
||||
@ -183,13 +243,13 @@ export const List: Component = () => {
|
||||
<img
|
||||
class="w-full h-full object-cover rounded-lg"
|
||||
src={`${base}/images/${image.ImageID}?token=${accessToken()}`}
|
||||
alt="List item"
|
||||
alt="Stack item"
|
||||
/>
|
||||
</a>
|
||||
<DeleteButton
|
||||
onDelete={() =>
|
||||
onDeleteImageFromStack(
|
||||
l().ID,
|
||||
s().ID,
|
||||
image.ImageID,
|
||||
)
|
||||
}
|
||||
@ -199,13 +259,14 @@ export const List: 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"
|
||||
@ -223,7 +284,7 @@ export const List: Component = () => {
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={l().Images.length === 0}>
|
||||
<Show when={s().Images.length === 0}>
|
||||
<div class="px-6 py-12 text-center text-neutral-500">
|
||||
<p class="text-lg">
|
||||
No images in this list yet
|
@ -24,6 +24,7 @@ class SharedToken: Plugin {
|
||||
}
|
||||
|
||||
sharedDefaults.set(token, forKey: sharedTokenKey)
|
||||
sharedDefaults.synchronize()
|
||||
invoke.resolve()
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user