Add native Web Share API for photo sharing
- Install react-share library and add native sharing to ImageModal - Replace Facebook-specific sharing with Web Share API for broader compatibility - Extract GPS location from EXIF data for enhanced share text - Support file sharing with fallbacks to URL/clipboard for unsupported devices - Add smart share text generation with location coordinates and camera info - Perfect for private hosts - shares actual files without external URLs - Update CLAUDE.md with completed roadmap items and coding standards 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5c3ad988f5
commit
96e6f4676a
27
CLAUDE.md
27
CLAUDE.md
@ -11,12 +11,29 @@
|
|||||||
[x] Configure next/font
|
[x] Configure next/font
|
||||||
[x] Set up TypeScript
|
[x] Set up TypeScript
|
||||||
[x] initialize git repo with appropriate .gitignore
|
[x] initialize git repo with appropriate .gitignore
|
||||||
[ ] Create responsive layout with Tailwind CSS
|
[x] Create responsive layout with Tailwind CSS
|
||||||
[ ] Integrate localdb for backend photo index data
|
[x] Integrate localdb for backend photo index data
|
||||||
[ ] create service to index photos from local filesystem
|
[x] create service to index photos from local filesystem
|
||||||
[ ] Create photo gallery page
|
[x] Create photo gallery page
|
||||||
[ ] Implement photo organization features (albums, tags, moving files)
|
[ ] Implement photo organization features (albums, tags, moving files)
|
||||||
[ ] Optimize for performance and SEO
|
[ ] Optimize for performance and SEO
|
||||||
|
|
||||||
|
|
||||||
- I'll run dev myself
|
# Claude Instructions
|
||||||
|
- Never automatically change or add files without user confirmation
|
||||||
|
- Never try to run the dev server, user will always manually run dev
|
||||||
|
- Run builds automatically to check for errors
|
||||||
|
|
||||||
|
# Code Standards
|
||||||
|
- Files over 300 lines should be split into multiple files
|
||||||
|
- Use functional components with hooks
|
||||||
|
- Use Tailwind CSS for all styling, no custom CSS unless absolutely necessary
|
||||||
|
- Use next/image for all images
|
||||||
|
- Use next/font for all fonts
|
||||||
|
- Write clear, concise, and well-documented code
|
||||||
|
- Double check approach against current frameworks and libraries
|
||||||
|
- Always ask for user confirmation before making large changes
|
||||||
|
|
||||||
|
# Audit
|
||||||
|
- When asked if there are libraries to accomblish custom functionality, check npm
|
||||||
|
- When asked for alternatives give multiple options with pros and cons
|
43
package-lock.json
generated
43
package-lock.json
generated
@ -17,6 +17,7 @@
|
|||||||
"next": "^15.5.0",
|
"next": "^15.5.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-share": "^5.2.2",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -1258,6 +1259,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classnames": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/client-only": {
|
"node_modules/client-only": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||||
@ -1326,6 +1333,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "2.6.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||||
|
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
@ -1582,6 +1598,14 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonp": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonp/-/jsonp-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-pfog5gdDxPdV4eP7Kg87M8/bHgshlZ5pybl+yKxAnCZ5O7lCIn7Ixydj03wOlnDQesky2BPyA91SQ+5Y/mNwzw==",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^2.1.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.30.1",
|
"version": "1.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
|
||||||
@ -1920,6 +1944,12 @@
|
|||||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@ -2207,6 +2237,19 @@
|
|||||||
"react": "^19.1.1"
|
"react": "^19.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-share": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-share/-/react-share-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-z0nbOX6X6vHHWAvXduNkYeJUKTKNpKM5Xpmc5a2BxjJhUWl+sE7AsSEMmYEUj2DuDjZr5m7KFIGF0sQPKcUN6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"jsonp": "^0.2.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
"next": "^15.5.0",
|
"next": "^15.5.0",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-share": "^5.2.2",
|
||||||
"sharp": "^0.34.3"
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { IconX, IconZoomIn, IconZoomOut, IconMaximize, IconRotate, IconChevronLeft, IconChevronRight } from '@tabler/icons-react'
|
import { IconX, IconZoomIn, IconZoomOut, IconMaximize, IconRotate, IconChevronLeft, IconChevronRight, IconShare } from '@tabler/icons-react'
|
||||||
import { Photo } from '@/types/photo'
|
import { Photo } from '@/types/photo'
|
||||||
|
|
||||||
interface ImageModalProps {
|
interface ImageModalProps {
|
||||||
@ -242,6 +242,91 @@ export default function ImageModal({ photo, isOpen, onClose, photos = [], onNavi
|
|||||||
|
|
||||||
const metaInfo = formatMetadata()
|
const metaInfo = formatMetadata()
|
||||||
|
|
||||||
|
// Extract location information for social sharing
|
||||||
|
const getLocationInfo = () => {
|
||||||
|
let metadata: any = {}
|
||||||
|
try {
|
||||||
|
metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const exif = metadata.exif || {}
|
||||||
|
if (exif.gps && (exif.gps.latitude || exif.gps.longitude)) {
|
||||||
|
return {
|
||||||
|
latitude: exif.gps.latitude,
|
||||||
|
longitude: exif.gps.longitude,
|
||||||
|
locationString: `📍 ${exif.gps.latitude?.toFixed(4)}, ${exif.gps.longitude?.toFixed(4)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const locationInfo = getLocationInfo()
|
||||||
|
|
||||||
|
// Build enhanced share quote with location
|
||||||
|
const buildShareQuote = () => {
|
||||||
|
const baseQuote = `Check out this photo: ${photo.filename}`
|
||||||
|
|
||||||
|
if (locationInfo) {
|
||||||
|
return `${baseQuote} ${locationInfo.locationString}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metaInfo?.camera) {
|
||||||
|
return `${baseQuote} - Shot with ${metaInfo.camera}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseQuote
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native sharing with actual file
|
||||||
|
const handleNativeShare = async () => {
|
||||||
|
try {
|
||||||
|
// Check if Web Share API is supported
|
||||||
|
if (!navigator.share) {
|
||||||
|
// Fallback to copying URL to clipboard
|
||||||
|
await navigator.clipboard.writeText(`${window.location.origin}/api/photos/${photo.id}/full`)
|
||||||
|
alert('Photo URL copied to clipboard!')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the image as a blob
|
||||||
|
const response = await fetch(`/api/photos/${photo.id}/full`)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch image')
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const file = new File([blob], photo.filename, { type: blob.type })
|
||||||
|
|
||||||
|
// Check if file sharing is supported
|
||||||
|
if (navigator.canShare && !navigator.canShare({ files: [file] })) {
|
||||||
|
// Fallback to sharing just text and URL
|
||||||
|
await navigator.share({
|
||||||
|
title: photo.filename,
|
||||||
|
text: buildShareQuote(),
|
||||||
|
url: `${window.location.origin}/api/photos/${photo.id}/full`
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Share with the actual file
|
||||||
|
await navigator.share({
|
||||||
|
title: photo.filename,
|
||||||
|
text: buildShareQuote(),
|
||||||
|
files: [file]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Sharing failed:', error)
|
||||||
|
|
||||||
|
// Ultimate fallback - copy to clipboard
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(`${window.location.origin}/api/photos/${photo.id}/full`)
|
||||||
|
alert('Sharing failed, but photo URL copied to clipboard!')
|
||||||
|
} catch (clipboardError) {
|
||||||
|
console.error('Clipboard access failed:', clipboardError)
|
||||||
|
alert('Sharing not supported on this device')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-90 z-[9999] flex flex-col"
|
className="fixed inset-0 bg-black bg-opacity-90 z-[9999] flex flex-col"
|
||||||
@ -338,6 +423,17 @@ export default function ImageModal({ photo, isOpen, onClose, photos = [], onNavi
|
|||||||
<IconRotate className="w-5 h-5" />
|
<IconRotate className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Native Share Button */}
|
||||||
|
{mounted && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleNativeShare() }}
|
||||||
|
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
|
||||||
|
title={locationInfo ? "Share photo (includes location)" : "Share photo"}
|
||||||
|
>
|
||||||
|
<IconShare className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onClose() }}
|
onClick={(e) => { e.stopPropagation(); onClose() }}
|
||||||
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
|
className="p-2 rounded hover:bg-white hover:bg-opacity-10 transition-colors"
|
||||||
|
Loading…
Reference in New Issue
Block a user