Sosyal Duvar
This commit is contained in:
parent
a299ae099d
commit
c78845f411
14 changed files with 2412 additions and 8 deletions
1
ui/.env
1
ui/.env
|
|
@ -2,3 +2,4 @@ VITE_API_URL='https://localhost:44344'
|
|||
VITE_CDN_URL='http://localhost:4005'
|
||||
VITE_REACT_APP_VERSION=$npm_package_version
|
||||
VITE_AI_URL='https://ai.sozsoft.com/webhook/'
|
||||
VITE_GOOGLE_MAPS_API_KEY='AIzaSyAefS2rvF-xwq7OHpZ27UYxXPbMo6OwACc'
|
||||
50
ui/package-lock.json
generated
50
ui/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "kurs-platform-ui",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kurs-platform-ui",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@babel/generator": "^7.28.3",
|
||||
"@babel/parser": "^7.28.0",
|
||||
|
|
@ -32,6 +32,7 @@
|
|||
"devextreme": "^23.2.11",
|
||||
"devextreme-react": "^23.2.11",
|
||||
"easy-peasy": "^6.0.5",
|
||||
"emoji-picker-react": "^4.14.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"file-saver": "^2.0.2",
|
||||
"formik": "^2.4.6",
|
||||
|
|
@ -52,6 +53,7 @@
|
|||
"react-router-dom": "^6.14.1",
|
||||
"react-select": "^5.9.0",
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"yet-another-react-lightbox": "^3.25.0",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -6107,6 +6109,21 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-picker-react": {
|
||||
"version": "4.14.1",
|
||||
"resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.14.1.tgz",
|
||||
"integrity": "sha512-4N6dCHr8Z5P52ICdncSxrY0ZcgUtb9Y3o4eBsy6HLpOMhka9AoiWMZ4/hJK0SQNYMIb7MQ+/NT5dqotpo9OXVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"flairup": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
|
|
@ -7337,6 +7354,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/flairup": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz",
|
||||
"integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/flat-cache": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz",
|
||||
|
|
@ -13998,6 +14021,29 @@
|
|||
"node": ">= 14.6"
|
||||
}
|
||||
},
|
||||
"node_modules/yet-another-react-lightbox": {
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/yet-another-react-lightbox/-/yet-another-react-lightbox-3.25.0.tgz",
|
||||
"integrity": "sha512-NaCeEXCpdwoTvoOpxNK9gdW8+oHs79yVH+D2YeVQWRjH5i32e5CoXndAAFP2p8awzVYfSonherrE9JMTpfD3EA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16 || ^17 || ^18 || ^19",
|
||||
"@types/react-dom": "^16 || ^17 || ^18 || ^19",
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"devextreme": "^23.2.11",
|
||||
"devextreme-react": "^23.2.11",
|
||||
"easy-peasy": "^6.0.5",
|
||||
"emoji-picker-react": "^4.14.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"file-saver": "^2.0.2",
|
||||
"formik": "^2.4.6",
|
||||
|
|
@ -60,6 +61,7 @@
|
|||
"react-router-dom": "^6.14.1",
|
||||
"react-select": "^5.9.0",
|
||||
"redux-state-sync": "^3.1.4",
|
||||
"yet-another-react-lightbox": "^3.25.0",
|
||||
"yup": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
468
ui/src/components/intranet/SocialWall/CreatePost.tsx
Normal file
468
ui/src/components/intranet/SocialWall/CreatePost.tsx
Normal file
|
|
@ -0,0 +1,468 @@
|
|||
import React, { useState, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import classNames from 'classnames'
|
||||
import EmojiPicker, { EmojiClickData } from 'emoji-picker-react'
|
||||
import {
|
||||
HiOutlineChartBar,
|
||||
HiOutlineEmojiHappy,
|
||||
HiX,
|
||||
HiOutlineCollection,
|
||||
HiOutlineLocationMarker
|
||||
} from 'react-icons/hi'
|
||||
import MediaManager, { MediaItem } from './MediaManager'
|
||||
import LocationPicker, { Location } from './LocationPicker'
|
||||
|
||||
interface CreatePostProps {
|
||||
onCreatePost: (post: {
|
||||
content: string
|
||||
location?: Location
|
||||
media?: {
|
||||
type: 'mixed' | 'poll'
|
||||
mediaItems?: MediaItem[]
|
||||
poll?: {
|
||||
question: string
|
||||
options: Array<{ text: string }>
|
||||
}
|
||||
}
|
||||
}) => void
|
||||
}
|
||||
|
||||
const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||
const [content, setContent] = useState('')
|
||||
const [mediaType, setMediaType] = useState<'media' | 'poll' | null>(null)
|
||||
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
|
||||
const [location, setLocation] = useState<Location | null>(null)
|
||||
const [pollQuestion, setPollQuestion] = useState('')
|
||||
const [pollOptions, setPollOptions] = useState(['', ''])
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [showMediaManager, setShowMediaManager] = useState(false)
|
||||
const [showLocationPicker, setShowLocationPicker] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!content.trim() && mediaItems.length === 0 && !mediaType) return
|
||||
|
||||
let media = undefined
|
||||
|
||||
if (mediaType === 'media' && mediaItems.length > 0) {
|
||||
media = {
|
||||
type: 'mixed' as const,
|
||||
mediaItems
|
||||
}
|
||||
} else if (mediaType === 'poll' && pollQuestion && pollOptions.filter(o => o.trim()).length >= 2) {
|
||||
media = {
|
||||
type: 'poll' as const,
|
||||
poll: {
|
||||
question: pollQuestion,
|
||||
options: pollOptions.filter(o => o.trim()).map(text => ({ text }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onCreatePost({
|
||||
content,
|
||||
media,
|
||||
location: location || undefined
|
||||
})
|
||||
|
||||
// Reset form
|
||||
setContent('')
|
||||
setMediaType(null)
|
||||
setMediaItems([])
|
||||
setLocation(null)
|
||||
setPollQuestion('')
|
||||
setPollOptions(['', ''])
|
||||
setIsExpanded(false)
|
||||
setShowEmojiPicker(false)
|
||||
}
|
||||
|
||||
const handleEmojiClick = (emojiData: EmojiClickData) => {
|
||||
const emoji = emojiData.emoji
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const text = content
|
||||
const before = text.substring(0, start)
|
||||
const after = text.substring(end)
|
||||
|
||||
setContent(before + emoji + after)
|
||||
|
||||
// Set cursor position after emoji
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + emoji.length
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const addPollOption = () => {
|
||||
if (pollOptions.length < 6) {
|
||||
setPollOptions([...pollOptions, ''])
|
||||
}
|
||||
}
|
||||
|
||||
const removePollOption = (index: number) => {
|
||||
if (pollOptions.length > 2) {
|
||||
setPollOptions(pollOptions.filter((_, i) => i !== index))
|
||||
}
|
||||
}
|
||||
|
||||
const updatePollOption = (index: number, value: string) => {
|
||||
const newOptions = [...pollOptions]
|
||||
newOptions[index] = value
|
||||
setPollOptions(newOptions)
|
||||
}
|
||||
|
||||
const clearMedia = () => {
|
||||
setMediaType(null)
|
||||
setMediaItems([])
|
||||
setPollQuestion('')
|
||||
setPollOptions(['', ''])
|
||||
}
|
||||
|
||||
const removeMediaItem = (id: string) => {
|
||||
setMediaItems(mediaItems.filter((m) => m.id !== id))
|
||||
}
|
||||
|
||||
// Close emoji picker when clicking outside
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
|
||||
setShowEmojiPicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showEmojiPicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showEmojiPicker])
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-6">
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Text Input */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<img
|
||||
src="https://i.pravatar.cc/150?img=1"
|
||||
alt="Your avatar"
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onFocus={() => setIsExpanded(true)}
|
||||
onBlur={() => {
|
||||
// Eğer içerik, medya veya konum yoksa küçült
|
||||
if (!content.trim() && mediaItems.length === 0 && !location && !mediaType) {
|
||||
setIsExpanded(false)
|
||||
}
|
||||
}}
|
||||
placeholder="Ne düşünüyorsunuz?"
|
||||
className={classNames(
|
||||
'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none transition-all',
|
||||
isExpanded ? 'min-h-[120px]' : 'min-h-[48px]'
|
||||
)}
|
||||
rows={isExpanded ? 4 : 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Media Preview */}
|
||||
<AnimatePresence>
|
||||
{mediaType === 'media' && mediaItems.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Medyalar ({mediaItems.length})
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearMedia()
|
||||
}}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
title="Tüm medyaları kaldır"
|
||||
>
|
||||
Vazgeç
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{mediaItems.map((item) => (
|
||||
<div key={item.id} className="relative group">
|
||||
{item.type === 'image' ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt="Preview"
|
||||
className="w-full h-24 object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-24 bg-gray-900 rounded-lg relative">
|
||||
<video src={item.url} className="w-full h-full object-cover rounded-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-10 h-10 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-white border-b-8 border-b-transparent ml-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeMediaItem(item.id)}
|
||||
className="absolute -top-2 -right-2 p-1 bg-red-600 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<HiX className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-1 px-2 py-0.5 bg-black bg-opacity-70 text-white text-xs rounded">
|
||||
{item.type === 'image' ? '📷' : '🎥'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMediaManager(true)}
|
||||
className="h-24 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
>
|
||||
<HiOutlineCollection className="w-6 h-6 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{location && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">Konum</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLocation(null)}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
title="Konumu kaldır"
|
||||
>
|
||||
Vazgeç
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-700">
|
||||
<div className="flex items-start gap-2">
|
||||
<HiOutlineLocationMarker className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
|
||||
{location.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{location.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{mediaType === 'poll' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">Anket</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearMedia()
|
||||
}}
|
||||
className="text-sm text-red-600 hover:text-red-700 font-medium"
|
||||
title="Anketi kaldır"
|
||||
>
|
||||
Vazgeç
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={pollQuestion}
|
||||
onChange={(e) => setPollQuestion(e.target.value)}
|
||||
placeholder="Soru"
|
||||
className="w-full px-4 py-2 mb-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
{pollOptions.map((option, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={option}
|
||||
onChange={(e) => updatePollOption(index, e.target.value)}
|
||||
placeholder={`Seçenek ${index + 1}`}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
{pollOptions.length > 2 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePollOption(index)}
|
||||
className="p-2 text-gray-500 hover:text-red-600"
|
||||
>
|
||||
<HiX className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pollOptions.length < 6 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPollOption}
|
||||
className="mt-2 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||
>
|
||||
+ Seçenek Ekle
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Actions */}
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="flex items-center justify-between pt-3 border-t border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex gap-2 relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (mediaType === 'media' && mediaItems.length > 0) {
|
||||
// Eğer zaten medya varsa, yöneticiyi aç
|
||||
setShowMediaManager(true)
|
||||
} else {
|
||||
// Başka bir tip seçiliyse temizle ve medya modunu aç
|
||||
clearMedia()
|
||||
setMediaType('media')
|
||||
setShowMediaManager(true)
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
'p-2 rounded-full transition-colors',
|
||||
mediaType === 'media'
|
||||
? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
)}
|
||||
title={mediaType === 'media' ? 'Medyaları düzenle' : 'Medya ekle'}
|
||||
>
|
||||
<HiOutlineCollection className="w-5 h-5" />
|
||||
{mediaType === 'media' && mediaItems.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-xs rounded-full flex items-center justify-center">
|
||||
{mediaItems.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Başka bir tip seçiliyse temizle
|
||||
if (mediaType !== 'poll') {
|
||||
clearMedia()
|
||||
}
|
||||
setMediaType(mediaType === 'poll' ? null : 'poll')
|
||||
}}
|
||||
className={classNames(
|
||||
'p-2 rounded-full transition-colors',
|
||||
mediaType === 'poll'
|
||||
? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
)}
|
||||
title={mediaType === 'poll' ? 'Anketi kaldır' : 'Anket ekle'}
|
||||
>
|
||||
<HiOutlineChartBar className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors"
|
||||
title="Emoji ekle"
|
||||
>
|
||||
<HiOutlineEmojiHappy className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLocationPicker(true)}
|
||||
className={classNames(
|
||||
'p-2 rounded-full transition-colors',
|
||||
location
|
||||
? 'bg-blue-100 text-blue-600 dark:bg-blue-900 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
)}
|
||||
title={location ? 'Konumu değiştir' : 'Konum ekle'}
|
||||
>
|
||||
<HiOutlineLocationMarker className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Emoji Picker */}
|
||||
{showEmojiPicker && (
|
||||
<div ref={emojiPickerRef} className="absolute bottom-12 left-0 z-50">
|
||||
<EmojiPicker
|
||||
onEmojiClick={handleEmojiClick}
|
||||
autoFocusSearch={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() && mediaItems.length === 0 && !mediaType}
|
||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-full hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Paylaş
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</form>
|
||||
|
||||
{/* Media Manager Modal */}
|
||||
<AnimatePresence>
|
||||
{showMediaManager && (
|
||||
<MediaManager
|
||||
media={mediaItems}
|
||||
onChange={setMediaItems}
|
||||
onClose={() => setShowMediaManager(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Location Picker Modal */}
|
||||
<AnimatePresence>
|
||||
{showLocationPicker && (
|
||||
<LocationPicker
|
||||
onSelect={setLocation}
|
||||
onClose={() => setShowLocationPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreatePost
|
||||
97
ui/src/components/intranet/SocialWall/LocationMap.tsx
Normal file
97
ui/src/components/intranet/SocialWall/LocationMap.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react'
|
||||
import { HiOutlineExternalLink, HiOutlineLocationMarker } from 'react-icons/hi'
|
||||
import type { Location } from './LocationPicker'
|
||||
|
||||
interface LocationMapProps {
|
||||
location: Location
|
||||
className?: string
|
||||
showDirections?: boolean
|
||||
}
|
||||
|
||||
const LocationMap: React.FC<LocationMapProps> = ({
|
||||
location,
|
||||
className = '',
|
||||
showDirections = true
|
||||
}) => {
|
||||
const handleOpenGoogleMaps = () => {
|
||||
const url = `https://www.google.com/maps/search/?api=1&query=${location.lat},${location.lng}&query_place_id=${location.placeId || ''}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
|
||||
// Google Maps Static API URL (gerçek uygulamada API key eklenecek)
|
||||
const getMapImageUrl = () => {
|
||||
const { lat, lng } = location
|
||||
const zoom = 15
|
||||
const size = '600x300'
|
||||
const marker = `color:red|${lat},${lng}`
|
||||
|
||||
// Production'da gerçek API key kullanılacak
|
||||
// const apiKey = 'YOUR_GOOGLE_MAPS_API_KEY'
|
||||
// return `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lng}&zoom=${zoom}&size=${size}&markers=${marker}&key=${apiKey}`
|
||||
|
||||
// Demo için OpenStreetMap kullanıyoruz
|
||||
return `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01},${lat - 0.01},${lng + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lng}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative rounded-lg overflow-hidden bg-gray-200 dark:bg-gray-700 ${className}`}>
|
||||
{/* Map Container */}
|
||||
<div className="relative w-full h-64 group">
|
||||
{/* OpenStreetMap iframe for demo */}
|
||||
<iframe
|
||||
title={`Map of ${location.name}`}
|
||||
src={getMapImageUrl()}
|
||||
className="w-full h-full border-0"
|
||||
allowFullScreen
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Overlay with location info */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
{/* Location Info */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 text-white pointer-events-none">
|
||||
<div className="flex items-start gap-2">
|
||||
<HiOutlineLocationMarker className="w-5 h-5 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-lg mb-1 drop-shadow-lg">{location.name}</h3>
|
||||
<p className="text-sm text-white/90 drop-shadow-md line-clamp-2">
|
||||
{location.address}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click to open overlay - invisible but clickable */}
|
||||
<button
|
||||
onClick={handleOpenGoogleMaps}
|
||||
className="absolute inset-0 w-full h-full cursor-pointer group"
|
||||
aria-label="Google Maps'te aç"
|
||||
>
|
||||
<span className="sr-only">Google Maps'te aç</span>
|
||||
</button>
|
||||
|
||||
{/* Hover Effect */}
|
||||
<div className="absolute inset-0 bg-blue-600/0 group-hover:bg-blue-600/10 transition-colors duration-200" />
|
||||
</div>
|
||||
|
||||
{/* Directions Button */}
|
||||
{showDirections && (
|
||||
<div className="p-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={handleOpenGoogleMaps}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
<HiOutlineExternalLink className="w-5 h-5" />
|
||||
<span>Google Maps'te Aç</span>
|
||||
</button>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center">
|
||||
Yol tarifi almak için tıklayın
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocationMap
|
||||
386
ui/src/components/intranet/SocialWall/LocationPicker.tsx
Normal file
386
ui/src/components/intranet/SocialWall/LocationPicker.tsx
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { HiX, HiOutlineSearch, HiOutlineLocationMarker } from 'react-icons/hi'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export interface Location {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
lat: number
|
||||
lng: number
|
||||
placeId?: string
|
||||
}
|
||||
|
||||
interface LocationPickerProps {
|
||||
onSelect: (location: Location) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
// Google Maps API key - .env dosyasından alınmalı
|
||||
const GOOGLE_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google: any
|
||||
initGoogleMaps?: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const LocationPicker: React.FC<LocationPickerProps> = ({ onSelect, onClose }) => {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [locations, setLocations] = useState<Location[]>([])
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isGoogleLoaded, setIsGoogleLoaded] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const autocompleteServiceRef = useRef<any>(null)
|
||||
const placesServiceRef = useRef<any>(null)
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout>()
|
||||
const scriptLoadedRef = useRef(false)
|
||||
|
||||
// Google Maps SDK'yı yükle
|
||||
useEffect(() => {
|
||||
if (scriptLoadedRef.current) return
|
||||
|
||||
const loadGoogleMaps = () => {
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
setIsGoogleLoaded(true)
|
||||
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
|
||||
const mapDiv = document.createElement('div')
|
||||
const map = new window.google.maps.Map(mapDiv)
|
||||
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
|
||||
return
|
||||
}
|
||||
|
||||
if (!GOOGLE_API_KEY) {
|
||||
setError('Google Maps API anahtarı bulunamadı. Lütfen .env dosyasına VITE_GOOGLE_MAPS_API_KEY ekleyin.')
|
||||
return
|
||||
}
|
||||
|
||||
// Script zaten yüklendiyse sadece bekle
|
||||
const existingScript = document.querySelector('script[src*="maps.googleapis.com"]')
|
||||
if (existingScript) {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
clearInterval(checkInterval)
|
||||
setIsGoogleLoaded(true)
|
||||
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
|
||||
const mapDiv = document.createElement('div')
|
||||
const map = new window.google.maps.Map(mapDiv)
|
||||
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
|
||||
}
|
||||
}, 100)
|
||||
return
|
||||
}
|
||||
|
||||
// Yeni script ekle
|
||||
const script = document.createElement('script')
|
||||
script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_API_KEY}&libraries=places&language=tr`
|
||||
script.async = true
|
||||
script.defer = true
|
||||
|
||||
script.onload = () => {
|
||||
if (window.google && window.google.maps && window.google.maps.places) {
|
||||
setIsGoogleLoaded(true)
|
||||
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
|
||||
const mapDiv = document.createElement('div')
|
||||
const map = new window.google.maps.Map(mapDiv)
|
||||
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
|
||||
}
|
||||
}
|
||||
|
||||
script.onerror = () => {
|
||||
setError('Google Maps yüklenemedi. Lütfen internet bağlantınızı kontrol edin.')
|
||||
}
|
||||
|
||||
document.head.appendChild(script)
|
||||
scriptLoadedRef.current = true
|
||||
}
|
||||
|
||||
loadGoogleMaps()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
searchInputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Google Places Autocomplete ile konum arama
|
||||
useEffect(() => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
if (searchQuery.trim() === '') {
|
||||
setLocations([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isGoogleLoaded) {
|
||||
return
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Google Places Autocomplete Service kullan (CORS yok)
|
||||
autocompleteServiceRef.current.getPlacePredictions(
|
||||
{
|
||||
input: searchQuery,
|
||||
componentRestrictions: { country: 'tr' },
|
||||
language: 'tr'
|
||||
},
|
||||
async (predictions: any, status: any) => {
|
||||
if (status === window.google.maps.places.PlacesServiceStatus.ZERO_RESULTS) {
|
||||
setLocations([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (status !== window.google.maps.places.PlacesServiceStatus.OK) {
|
||||
setError('Konum arama başarısız')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!predictions || predictions.length === 0) {
|
||||
setLocations([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Her bir prediction için detaylı bilgi al
|
||||
const detailedLocations: Location[] = []
|
||||
let completed = 0
|
||||
|
||||
predictions.forEach((prediction: any) => {
|
||||
placesServiceRef.current.getDetails(
|
||||
{
|
||||
placeId: prediction.place_id,
|
||||
fields: ['name', 'formatted_address', 'geometry', 'place_id']
|
||||
},
|
||||
(place: any, placeStatus: any) => {
|
||||
completed++
|
||||
|
||||
if (placeStatus === window.google.maps.places.PlacesServiceStatus.OK && place) {
|
||||
detailedLocations.push({
|
||||
id: place.place_id,
|
||||
name: place.name,
|
||||
address: place.formatted_address,
|
||||
lat: place.geometry.location.lat(),
|
||||
lng: place.geometry.location.lng(),
|
||||
placeId: place.place_id
|
||||
})
|
||||
}
|
||||
|
||||
// Tüm istekler tamamlandıysa state'i güncelle
|
||||
if (completed === predictions.length) {
|
||||
setLocations(detailedLocations)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Location search error:', err)
|
||||
setError('Konum arama sırasında bir hata oluştu')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, 500) // 500ms debounce
|
||||
|
||||
return () => {
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [searchQuery, isGoogleLoaded])
|
||||
|
||||
const handleSelect = (location: Location) => {
|
||||
setSelectedLocation(location)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedLocation) {
|
||||
onSelect(selectedLocation)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Konum Ekle</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors"
|
||||
>
|
||||
<HiX className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="relative">
|
||||
<HiOutlineSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Konum ara..."
|
||||
disabled={!isGoogleLoaded}
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
{!isGoogleLoaded && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Google Maps yükleniyor...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location List */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{!isGoogleLoaded ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Google Maps yükleniyor...</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-500 dark:text-gray-400">Konumlar aranıyor...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-12">
|
||||
<HiOutlineLocationMarker className="w-16 h-16 mx-auto mb-4 text-red-400" />
|
||||
<p className="text-red-500 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
) : searchQuery.trim() === '' ? (
|
||||
<div className="text-center py-12">
|
||||
<HiOutlineSearch className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Aramak istediğiniz konumu yazın
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
||||
Örn: Taksim, İstanbul
|
||||
</p>
|
||||
</div>
|
||||
) : locations.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<HiOutlineLocationMarker className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Konum bulunamadı. Farklı bir arama yapın.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{locations.map((location) => (
|
||||
<button
|
||||
key={location.id}
|
||||
onClick={() => handleSelect(location)}
|
||||
className={classNames(
|
||||
'w-full text-left p-3 rounded-lg transition-all hover:bg-gray-50 dark:hover:bg-gray-700',
|
||||
selectedLocation?.id === location.id
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 border-2 border-blue-500'
|
||||
: 'border-2 border-transparent'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1">
|
||||
<HiOutlineLocationMarker
|
||||
className={classNames(
|
||||
'w-5 h-5',
|
||||
selectedLocation?.id === location.id
|
||||
? 'text-blue-600'
|
||||
: 'text-gray-400'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3
|
||||
className={classNames(
|
||||
'font-semibold mb-1',
|
||||
selectedLocation?.id === location.id
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
)}
|
||||
>
|
||||
{location.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{location.address}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{location.lat.toFixed(4)}, {location.lng.toFixed(4)}
|
||||
</p>
|
||||
</div>
|
||||
{selectedLocation?.id === location.id && (
|
||||
<div className="mt-1">
|
||||
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-750">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{selectedLocation ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<HiOutlineLocationMarker className="w-4 h-4 text-blue-600" />
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{selectedLocation.name}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>Bir konum seçin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
İptal
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedLocation}
|
||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Ekle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LocationPicker
|
||||
72
ui/src/components/intranet/SocialWall/MediaLightbox.tsx
Normal file
72
ui/src/components/intranet/SocialWall/MediaLightbox.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import React from 'react'
|
||||
import Lightbox from 'yet-another-react-lightbox'
|
||||
import 'yet-another-react-lightbox/styles.css'
|
||||
import Video from 'yet-another-react-lightbox/plugins/video'
|
||||
import Zoom from 'yet-another-react-lightbox/plugins/zoom'
|
||||
import Counter from 'yet-another-react-lightbox/plugins/counter'
|
||||
import 'yet-another-react-lightbox/plugins/counter.css'
|
||||
|
||||
export interface LightboxMedia {
|
||||
type: 'image' | 'video'
|
||||
url?: string
|
||||
urls?: string[]
|
||||
}
|
||||
|
||||
interface MediaLightboxProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
media: LightboxMedia
|
||||
startIndex?: number
|
||||
}
|
||||
|
||||
const MediaLightbox: React.FC<MediaLightboxProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
media,
|
||||
startIndex = 0
|
||||
}) => {
|
||||
const slides = React.useMemo(() => {
|
||||
if (media.type === 'video' && media.url) {
|
||||
return [
|
||||
{
|
||||
type: 'video' as const,
|
||||
sources: [
|
||||
{
|
||||
src: media.url,
|
||||
type: 'video/mp4'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const urls = media.urls || (media.url ? [media.url] : [])
|
||||
return urls.map((url) => ({
|
||||
src: url
|
||||
}))
|
||||
}, [media])
|
||||
|
||||
return (
|
||||
<Lightbox
|
||||
open={isOpen}
|
||||
close={onClose}
|
||||
slides={slides}
|
||||
index={startIndex}
|
||||
plugins={[Video, Zoom, Counter]}
|
||||
counter={{ container: { style: { top: 'unset', bottom: 0 } } }}
|
||||
zoom={{
|
||||
maxZoomPixelRatio: 3,
|
||||
scrollToZoom: true
|
||||
}}
|
||||
video={{
|
||||
controls: true,
|
||||
autoPlay: false
|
||||
}}
|
||||
styles={{
|
||||
container: { backgroundColor: 'rgba(0, 0, 0, 0.95)' }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaLightbox
|
||||
243
ui/src/components/intranet/SocialWall/MediaManager.tsx
Normal file
243
ui/src/components/intranet/SocialWall/MediaManager.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import React, { useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { HiX, HiPlus, HiOutlineLink, HiOutlineUpload } from 'react-icons/hi'
|
||||
import classNames from 'classnames'
|
||||
|
||||
export interface MediaItem {
|
||||
id: string
|
||||
type: 'image' | 'video'
|
||||
url: string
|
||||
file?: File
|
||||
}
|
||||
|
||||
interface MediaManagerProps {
|
||||
media: MediaItem[]
|
||||
onChange: (media: MediaItem[]) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose }) => {
|
||||
const [activeTab, setActiveTab] = useState<'upload' | 'url'>('upload')
|
||||
const [urlInput, setUrlInput] = useState('')
|
||||
const [mediaType, setMediaType] = useState<'image' | 'video'>('image')
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
|
||||
const newMedia: MediaItem[] = Array.from(files).map((file) => ({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
type: file.type.startsWith('video/') ? 'video' : 'image',
|
||||
url: URL.createObjectURL(file),
|
||||
file
|
||||
}))
|
||||
|
||||
onChange([...media, ...newMedia])
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleUrlAdd = () => {
|
||||
if (!urlInput.trim()) return
|
||||
|
||||
const newMedia: MediaItem = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
type: mediaType,
|
||||
url: urlInput
|
||||
}
|
||||
|
||||
onChange([...media, newMedia])
|
||||
setUrlInput('')
|
||||
}
|
||||
|
||||
const removeMedia = (id: string) => {
|
||||
onChange(media.filter((m) => m.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">Medya Ekle</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors"
|
||||
>
|
||||
<HiX className="w-5 h-5 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 px-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('upload')}
|
||||
className={classNames(
|
||||
'px-4 py-3 font-medium border-b-2 transition-colors',
|
||||
activeTab === 'upload'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<HiOutlineUpload className="w-5 h-5" />
|
||||
<span>Bilgisayarımdan Seç</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('url')}
|
||||
className={classNames(
|
||||
'px-4 py-3 font-medium border-b-2 transition-colors',
|
||||
activeTab === 'url'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<HiOutlineLink className="w-5 h-5" />
|
||||
<span>URL ile Ekle</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 overflow-y-auto max-h-[calc(90vh-240px)]">
|
||||
{activeTab === 'upload' ? (
|
||||
<div>
|
||||
<label className="block">
|
||||
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors cursor-pointer">
|
||||
<HiOutlineUpload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-700 dark:text-gray-300 font-medium mb-1">
|
||||
Dosya seçmek için tıklayın
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Resim veya Video (PNG, JPG, GIF, MP4, MOV)
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Medya Tipi
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setMediaType('image')}
|
||||
className={classNames(
|
||||
'flex-1 py-2 px-4 rounded-lg font-medium transition-colors',
|
||||
mediaType === 'image'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
Resim
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMediaType('video')}
|
||||
className={classNames(
|
||||
'flex-1 py-2 px-4 rounded-lg font-medium transition-colors',
|
||||
mediaType === 'video'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
)}
|
||||
>
|
||||
Video
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleUrlAdd()}
|
||||
placeholder={`${mediaType === 'image' ? 'Resim' : 'Video'} URL'si girin`}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleUrlAdd}
|
||||
disabled={!urlInput.trim()}
|
||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Ekle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Media Preview */}
|
||||
{media.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Eklenen Medyalar ({media.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{media.map((item) => (
|
||||
<div key={item.id} className="relative group">
|
||||
{item.type === 'image' ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt="Media preview"
|
||||
className="w-full h-24 object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-24 bg-gray-900 rounded-lg flex items-center justify-center">
|
||||
<video src={item.url} className="w-full h-full object-cover rounded-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-10 h-10 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
|
||||
<div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-white border-b-8 border-b-transparent ml-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeMedia(item.id)}
|
||||
className="absolute -top-2 -right-2 p-1 bg-red-600 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<HiX className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="absolute bottom-1 left-1 px-2 py-0.5 bg-black bg-opacity-70 text-white text-xs rounded">
|
||||
{item.type === 'image' ? '📷' : '🎥'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||
>
|
||||
İptal
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={media.length === 0}
|
||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Tamam ({media.length})
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MediaManager
|
||||
433
ui/src/components/intranet/SocialWall/PostItem.tsx
Normal file
433
ui/src/components/intranet/SocialWall/PostItem.tsx
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import classNames from 'classnames'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/tr'
|
||||
import {
|
||||
HiHeart,
|
||||
HiOutlineHeart,
|
||||
HiOutlineChatAlt2,
|
||||
HiOutlineTrash,
|
||||
HiOutlinePaperAirplane,
|
||||
} from 'react-icons/hi'
|
||||
import { SocialPost } from '../../../mocks/mockSocialPosts'
|
||||
import MediaLightbox from './MediaLightbox'
|
||||
import LocationMap from './LocationMap'
|
||||
import UserProfileCard from './UserProfileCard'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.locale('tr')
|
||||
|
||||
interface PostItemProps {
|
||||
post: SocialPost
|
||||
onLike: (postId: string) => void
|
||||
onComment: (postId: string, content: string) => void
|
||||
onDelete: (postId: string) => void
|
||||
onVote: (postId: string, optionId: string) => void
|
||||
}
|
||||
|
||||
const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete, onVote }) => {
|
||||
const [showComments, setShowComments] = useState(false)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [showAllImages, setShowAllImages] = useState(false)
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0)
|
||||
const [showUserCard, setShowUserCard] = useState(false)
|
||||
const [hoveredCommentAuthor, setHoveredCommentAuthor] = useState<string | null>(null)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
// Intersection Observer for video autoplay/pause
|
||||
useEffect(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Video ekranda görünür - oynat
|
||||
video.play().catch(err => {
|
||||
console.log('Video autoplay failed:', err)
|
||||
})
|
||||
} else {
|
||||
// Video ekrandan çıktı - durdur
|
||||
video.pause()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
threshold: 0.5 // Video %50 görünür olduğunda oynat
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(video)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [post.media?.type])
|
||||
|
||||
const handleSubmitComment = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (commentText.trim()) {
|
||||
onComment(post.id, commentText)
|
||||
setCommentText('')
|
||||
}
|
||||
}
|
||||
|
||||
const getImageLayout = (images: string[]) => {
|
||||
const count = images.length
|
||||
if (count === 1) return 'single'
|
||||
if (count === 2) return 'double'
|
||||
if (count === 3) return 'triple'
|
||||
return 'multiple'
|
||||
}
|
||||
|
||||
const renderMedia = () => {
|
||||
if (!post.media) return null
|
||||
|
||||
switch (post.media.type) {
|
||||
case 'image':
|
||||
if (post.media.urls && post.media.urls.length > 0) {
|
||||
const layout = getImageLayout(post.media.urls)
|
||||
const displayImages = showAllImages ? post.media.urls : post.media.urls.slice(0, 4)
|
||||
const hasMore = post.media.urls.length > 4
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames('mt-3 rounded-lg overflow-hidden', {
|
||||
'grid gap-1': layout !== 'single',
|
||||
'grid-cols-2': layout === 'double' || layout === 'multiple',
|
||||
'grid-cols-3': layout === 'triple'
|
||||
})}
|
||||
>
|
||||
{displayImages.map((url, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('relative', {
|
||||
'col-span-2': layout === 'triple' && index === 0,
|
||||
'aspect-video': layout === 'single',
|
||||
'aspect-square': layout !== 'single'
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`Post image ${index + 1}`}
|
||||
className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
setLightboxIndex(index)
|
||||
setLightboxOpen(true)
|
||||
}}
|
||||
/>
|
||||
{hasMore && index === 3 && !showAllImages && post.media?.urls && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-60 flex items-center justify-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowAllImages(true)
|
||||
}}
|
||||
>
|
||||
<span className="text-white text-2xl font-bold">
|
||||
+{post.media.urls.length - 4}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<MediaLightbox
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
media={{ type: 'image', urls: post.media.urls }}
|
||||
startIndex={lightboxIndex}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
} else if (post.media.url) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="mt-3 rounded-lg overflow-hidden cursor-pointer"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
>
|
||||
<img
|
||||
src={post.media.url}
|
||||
alt="Post"
|
||||
className="w-full object-cover max-h-96 hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
<MediaLightbox
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
media={{ type: 'image', url: post.media.url }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'video':
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="mt-3 rounded-lg overflow-hidden cursor-pointer relative group"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className="w-full max-h-96"
|
||||
src={post.media.url}
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
>
|
||||
Tarayıcınız video etiketini desteklemiyor.
|
||||
</video>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all flex items-center justify-center">
|
||||
<div className="w-16 h-16 bg-white bg-opacity-90 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="w-0 h-0 border-t-10 border-t-transparent border-l-16 border-l-blue-600 border-b-10 border-b-transparent ml-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MediaLightbox
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
media={{ type: 'video', url: post.media.url || '' }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
case 'poll':
|
||||
if (post.media.poll) {
|
||||
const poll = post.media.poll
|
||||
const isExpired = new Date() > poll.endsAt
|
||||
const hasVoted = !!poll.userVote
|
||||
|
||||
return (
|
||||
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{poll.question}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{poll.options.map((option) => {
|
||||
const percentage =
|
||||
poll.totalVotes > 0 ? (option.votes / poll.totalVotes) * 100 : 0
|
||||
const isSelected = poll.userVote === option.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
onClick={() => !hasVoted && !isExpired && onVote(post.id, option.id)}
|
||||
disabled={hasVoted || isExpired}
|
||||
className={classNames(
|
||||
'w-full text-left p-3 rounded-lg relative overflow-hidden transition-all',
|
||||
{
|
||||
'bg-blue-100 dark:bg-blue-900 border-2 border-blue-500':
|
||||
isSelected,
|
||||
'bg-white dark:bg-gray-600 hover:bg-gray-50 dark:hover:bg-gray-500':
|
||||
!isSelected && !hasVoted && !isExpired,
|
||||
'bg-white dark:bg-gray-600 cursor-not-allowed':
|
||||
hasVoted || isExpired
|
||||
}
|
||||
)}
|
||||
>
|
||||
{hasVoted && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-blue-200 dark:bg-blue-800 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex justify-between items-center">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{option.text}
|
||||
</span>
|
||||
{hasVoted && (
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{percentage.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{poll.totalVotes} oy • {isExpired ? 'Sona erdi' : dayjs(poll.endsAt).fromNow() + ' bitiyor'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setShowUserCard(true)}
|
||||
onMouseLeave={() => setShowUserCard(false)}
|
||||
>
|
||||
<img
|
||||
src={post.author.avatar}
|
||||
alt={post.author.name}
|
||||
className="w-12 h-12 rounded-full object-cover cursor-pointer ring-2 ring-transparent hover:ring-blue-500 transition-all"
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{showUserCard && (
|
||||
<UserProfileCard user={post.author} position="bottom" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{post.author.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{post.author.title} • {dayjs(post.createdAt).fromNow()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{post.isOwnPost && (
|
||||
<button
|
||||
onClick={() => onDelete(post.id)}
|
||||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-full transition-colors"
|
||||
title="Gönderiyi sil"
|
||||
>
|
||||
<HiOutlineTrash className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-3">
|
||||
<p className="text-gray-800 dark:text-gray-200 whitespace-pre-wrap">{post.content}</p>
|
||||
{renderMedia()}
|
||||
|
||||
{/* Location */}
|
||||
{post.location && (
|
||||
<div className="mt-3">
|
||||
<LocationMap location={post.location} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-6 pt-3 border-t border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onLike(post.id)}
|
||||
className={classNames(
|
||||
'flex items-center gap-2 transition-colors',
|
||||
post.likes.isLiked
|
||||
? 'text-red-600 hover:text-red-700'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-red-600'
|
||||
)}
|
||||
>
|
||||
{post.likes.isLiked ? (
|
||||
<HiHeart className="w-5 h-5" />
|
||||
) : (
|
||||
<HiOutlineHeart className="w-5 h-5" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{post.likes.count}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowComments(!showComments)}
|
||||
className="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 transition-colors"
|
||||
>
|
||||
<HiOutlineChatAlt2 className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">{post.comments.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Comments Section */}
|
||||
<AnimatePresence>
|
||||
{showComments && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
{/* Comment Form */}
|
||||
<form onSubmit={handleSubmitComment} className="mb-4">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={commentText}
|
||||
onChange={(e) => setCommentText(e.target.value)}
|
||||
placeholder="Yorum yazın..."
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-full bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!commentText.trim()}
|
||||
className="p-2 bg-blue-600 text-white rounded-full hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<HiOutlinePaperAirplane className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Comments List */}
|
||||
<div className="space-y-3">
|
||||
{post.comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setHoveredCommentAuthor(comment.id)}
|
||||
onMouseLeave={() => setHoveredCommentAuthor(null)}
|
||||
>
|
||||
<img
|
||||
src={comment.author.avatar}
|
||||
alt={comment.author.name}
|
||||
className="w-8 h-8 rounded-full object-cover cursor-pointer ring-2 ring-transparent hover:ring-blue-500 transition-all"
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{hoveredCommentAuthor === comment.id && (
|
||||
<UserProfileCard
|
||||
user={{
|
||||
id: comment.author.id,
|
||||
name: comment.author.name,
|
||||
avatar: comment.author.avatar,
|
||||
title: 'Çalışan' // Default title for comments
|
||||
}}
|
||||
position="bottom"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="bg-gray-100 dark:bg-gray-700 rounded-lg px-4 py-2">
|
||||
<h4 className="font-semibold text-sm text-gray-900 dark:text-gray-100">
|
||||
{comment.author.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-800 dark:text-gray-200">{comment.content}</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-4">
|
||||
{dayjs(comment.createdAt).fromNow()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostItem
|
||||
101
ui/src/components/intranet/SocialWall/UserProfileCard.tsx
Normal file
101
ui/src/components/intranet/SocialWall/UserProfileCard.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { HiMail, HiPhone, HiBriefcase, HiLocationMarker } from 'react-icons/hi'
|
||||
|
||||
interface UserProfileCardProps {
|
||||
user: {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
title: string
|
||||
email?: string
|
||||
phone?: string
|
||||
department?: string
|
||||
location?: string
|
||||
}
|
||||
position?: 'top' | 'bottom'
|
||||
}
|
||||
|
||||
const UserProfileCard: React.FC<UserProfileCardProps> = ({ user, position = 'bottom' }) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: position === 'bottom' ? -10 : 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: position === 'bottom' ? -10 : 10 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`absolute left-0 ${
|
||||
position === 'bottom' ? 'top-full mt-2' : 'bottom-full mb-2'
|
||||
} z-50 w-72 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 p-4`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3 mb-3 pb-3 border-b border-gray-200 dark:border-gray-700">
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.name}
|
||||
className="w-16 h-16 rounded-full object-cover ring-2 ring-blue-500"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-gray-100 text-lg mb-1">
|
||||
{user.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-1">
|
||||
<HiBriefcase className="w-4 h-4" />
|
||||
{user.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Info */}
|
||||
<div className="space-y-2">
|
||||
{user.email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<HiMail className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<a
|
||||
href={`mailto:${user.email}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline truncate"
|
||||
>
|
||||
{user.email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<HiPhone className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<a
|
||||
href={`tel:${user.phone}`}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{user.phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.department && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<HiBriefcase className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{user.department}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.location && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<HiLocationMarker className="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<span className="text-gray-700 dark:text-gray-300">{user.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow indicator */}
|
||||
<div
|
||||
className={`absolute left-6 ${
|
||||
position === 'bottom' ? '-top-2' : '-bottom-2'
|
||||
} w-4 h-4 bg-white dark:bg-gray-800 border-l border-t border-gray-200 dark:border-gray-700 transform ${
|
||||
position === 'bottom' ? 'rotate-45' : '-rotate-135'
|
||||
}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UserProfileCard
|
||||
236
ui/src/components/intranet/SocialWall/index.tsx
Normal file
236
ui/src/components/intranet/SocialWall/index.tsx
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import React, { useState } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { mockSocialPosts, SocialPost } from '../../../mocks/mockSocialPosts'
|
||||
import PostItem from './PostItem'
|
||||
import { MediaItem } from './MediaManager'
|
||||
import CreatePost from './CreatePost'
|
||||
import { Location } from './LocationPicker'
|
||||
|
||||
const SocialWall: React.FC = () => {
|
||||
const [posts, setPosts] = useState<SocialPost[]>(mockSocialPosts)
|
||||
const [filter, setFilter] = useState<'all' | 'mine'>('all')
|
||||
|
||||
const handleCreatePost = (postData: {
|
||||
content: string
|
||||
location?: Location
|
||||
media?: {
|
||||
type: 'mixed' | 'poll'
|
||||
mediaItems?: MediaItem[]
|
||||
poll?: {
|
||||
question: string
|
||||
options: Array<{ text: string }>
|
||||
}
|
||||
}
|
||||
}) => {
|
||||
let mediaForPost = undefined
|
||||
|
||||
if (postData.media) {
|
||||
if (postData.media.type === 'mixed' && postData.media.mediaItems) {
|
||||
// Convert MediaItems to post format
|
||||
const images = postData.media.mediaItems.filter(m => m.type === 'image')
|
||||
const videos = postData.media.mediaItems.filter(m => m.type === 'video')
|
||||
|
||||
if (images.length > 0 && videos.length === 0) {
|
||||
mediaForPost = {
|
||||
type: 'image' as const,
|
||||
urls: images.map(i => i.url)
|
||||
}
|
||||
} else if (videos.length > 0 && images.length === 0) {
|
||||
mediaForPost = {
|
||||
type: 'video' as const,
|
||||
url: videos[0].url
|
||||
}
|
||||
} else if (images.length > 0 || videos.length > 0) {
|
||||
// Mixed media - use first image for now
|
||||
mediaForPost = {
|
||||
type: 'image' as const,
|
||||
urls: images.map(i => i.url)
|
||||
}
|
||||
}
|
||||
} else if (postData.media.type === 'poll' && postData.media.poll) {
|
||||
mediaForPost = {
|
||||
type: 'poll' as const,
|
||||
poll: {
|
||||
question: postData.media.poll.question,
|
||||
options: postData.media.poll.options.map((opt, index) => ({
|
||||
id: `opt-${index}`,
|
||||
text: opt.text,
|
||||
votes: 0
|
||||
})),
|
||||
totalVotes: 0,
|
||||
endsAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newPost: SocialPost = {
|
||||
id: Date.now().toString(),
|
||||
author: {
|
||||
id: 'currentUser',
|
||||
name: 'Siz',
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
title: 'Çalışan'
|
||||
},
|
||||
content: postData.content,
|
||||
createdAt: new Date(),
|
||||
media: mediaForPost,
|
||||
location: postData.location,
|
||||
likes: {
|
||||
count: 0,
|
||||
isLiked: false,
|
||||
users: []
|
||||
},
|
||||
comments: [],
|
||||
isOwnPost: true
|
||||
}
|
||||
|
||||
setPosts([newPost, ...posts])
|
||||
}
|
||||
|
||||
const handleLike = (postId: string) => {
|
||||
setPosts(
|
||||
posts.map((post) => {
|
||||
if (post.id === postId) {
|
||||
return {
|
||||
...post,
|
||||
likes: {
|
||||
...post.likes,
|
||||
count: post.likes.isLiked ? post.likes.count - 1 : post.likes.count + 1,
|
||||
isLiked: !post.likes.isLiked
|
||||
}
|
||||
}
|
||||
}
|
||||
return post
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleComment = (postId: string, content: string) => {
|
||||
setPosts(
|
||||
posts.map((post) => {
|
||||
if (post.id === postId) {
|
||||
const newComment = {
|
||||
id: Date.now().toString(),
|
||||
author: {
|
||||
id: 'currentUser',
|
||||
name: 'Siz',
|
||||
avatar: 'https://i.pravatar.cc/150?img=1'
|
||||
},
|
||||
content,
|
||||
createdAt: new Date()
|
||||
}
|
||||
return {
|
||||
...post,
|
||||
comments: [...post.comments, newComment]
|
||||
}
|
||||
}
|
||||
return post
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const handleDelete = (postId: string) => {
|
||||
if (window.confirm('Bu gönderiyi silmek istediğinizden emin misiniz?')) {
|
||||
setPosts(posts.filter((post) => post.id !== postId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = (postId: string, optionId: string) => {
|
||||
setPosts(
|
||||
posts.map((post) => {
|
||||
if (post.id === postId && post.media?.type === 'poll' && post.media.poll) {
|
||||
const poll = post.media.poll
|
||||
|
||||
// If user already voted, don't allow voting again
|
||||
if (poll.userVote) {
|
||||
return post
|
||||
}
|
||||
|
||||
return {
|
||||
...post,
|
||||
media: {
|
||||
...post.media,
|
||||
poll: {
|
||||
...poll,
|
||||
options: poll.options.map((opt) =>
|
||||
opt.id === optionId ? { ...opt, votes: opt.votes + 1 } : opt
|
||||
),
|
||||
totalVotes: poll.totalVotes + 1,
|
||||
userVote: optionId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return post
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const filteredPosts = filter === 'mine' ? posts.filter((post) => post.isOwnPost) : posts
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto py-6 px-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-gray-100 mb-2">
|
||||
Sosyal Duvar
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Ekip arkadaşlarınızla düşüncelerinizi paylaşın
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`pb-3 px-1 border-b-2 transition-colors font-medium ${
|
||||
filter === 'all'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Tüm Gönderiler
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('mine')}
|
||||
className={`pb-3 px-1 border-b-2 transition-colors font-medium ${
|
||||
filter === 'mine'
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
Gönderilerim
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Post */}
|
||||
<CreatePost onCreatePost={handleCreatePost} />
|
||||
|
||||
{/* Posts Feed */}
|
||||
<AnimatePresence>
|
||||
{filteredPosts.length > 0 ? (
|
||||
filteredPosts.map((post) => (
|
||||
<PostItem
|
||||
key={post.id}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onComment={handleComment}
|
||||
onDelete={handleDelete}
|
||||
onVote={handleVote}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
||||
{filter === 'mine' ? 'Henüz bir gönderi paylaşmadınız.' : 'Henüz gönderi yok.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SocialWall
|
||||
312
ui/src/mocks/mockSocialPosts.ts
Normal file
312
ui/src/mocks/mockSocialPosts.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
export interface SocialPost {
|
||||
id: string
|
||||
author: {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
title: string
|
||||
email?: string
|
||||
phone?: string
|
||||
department?: string
|
||||
location?: string
|
||||
}
|
||||
content: string
|
||||
createdAt: Date
|
||||
location?: {
|
||||
id: string
|
||||
name: string
|
||||
address: string
|
||||
lat: number
|
||||
lng: number
|
||||
placeId?: string
|
||||
}
|
||||
media?: {
|
||||
type: 'image' | 'video' | 'poll'
|
||||
url?: string
|
||||
urls?: string[]
|
||||
poll?: {
|
||||
question: string
|
||||
options: Array<{
|
||||
id: string
|
||||
text: string
|
||||
votes: number
|
||||
}>
|
||||
totalVotes: number
|
||||
endsAt: Date
|
||||
userVote?: string
|
||||
}
|
||||
}
|
||||
likes: {
|
||||
count: number
|
||||
isLiked: boolean
|
||||
users: Array<{ id: string; name: string; avatar: string }>
|
||||
}
|
||||
comments: Array<{
|
||||
id: string
|
||||
author: {
|
||||
id: string
|
||||
name: string
|
||||
avatar: string
|
||||
}
|
||||
content: string
|
||||
createdAt: Date
|
||||
}>
|
||||
isOwnPost: boolean
|
||||
}
|
||||
|
||||
export const mockSocialPosts: SocialPost[] = [
|
||||
{
|
||||
id: '1',
|
||||
author: {
|
||||
id: 'user1',
|
||||
name: 'Ahmet Yılmaz',
|
||||
avatar: 'https://i.pravatar.cc/150?img=12',
|
||||
title: 'Yazılım Geliştirici',
|
||||
email: 'ahmet.yilmaz@sozsoft.com',
|
||||
phone: '+90 532 123 45 67',
|
||||
department: 'Yazılım Geliştirme',
|
||||
location: 'İstanbul, Türkiye'
|
||||
},
|
||||
content:
|
||||
'Yeni proje üzerinde çalışıyoruz! React ve TypeScript ile harika bir deneyim oluşturuyoruz. Ekip çalışması harika gidiyor! 🚀',
|
||||
createdAt: new Date('2024-10-15T10:30:00'),
|
||||
location: {
|
||||
id: '1',
|
||||
name: 'Taksim Meydanı',
|
||||
address: 'Taksim, Gümüşsuyu Mahallesi, 34437 Beyoğlu/İstanbul',
|
||||
lat: 41.0369,
|
||||
lng: 28.9850,
|
||||
placeId: 'ChIJBQRGmL25yhQRXwqRTHAwAAQ'
|
||||
},
|
||||
media: {
|
||||
type: 'image',
|
||||
url: 'https://images.unsplash.com/photo-1633356122544-f134324a6cee?w=800&q=80'
|
||||
},
|
||||
likes: {
|
||||
count: 24,
|
||||
isLiked: true,
|
||||
users: [
|
||||
{ id: 'user2', name: 'Ayşe Demir', avatar: 'https://i.pravatar.cc/150?img=5' },
|
||||
{ id: 'user3', name: 'Mehmet Kaya', avatar: 'https://i.pravatar.cc/150?img=8' }
|
||||
]
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
id: 'c1',
|
||||
author: {
|
||||
id: 'user2',
|
||||
name: 'Ayşe Demir',
|
||||
avatar: 'https://i.pravatar.cc/150?img=5'
|
||||
},
|
||||
content: 'Harika görünüyor! Başarılar 👏',
|
||||
createdAt: new Date('2024-10-15T11:00:00')
|
||||
},
|
||||
{
|
||||
id: 'c2',
|
||||
author: {
|
||||
id: 'user3',
|
||||
name: 'Mehmet Kaya',
|
||||
avatar: 'https://i.pravatar.cc/150?img=8'
|
||||
},
|
||||
content: 'TypeScript gerçekten fark yaratıyor!',
|
||||
createdAt: new Date('2024-10-15T11:30:00')
|
||||
}
|
||||
],
|
||||
isOwnPost: false
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
author: {
|
||||
id: 'currentUser',
|
||||
name: 'Siz',
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
title: 'Proje Yöneticisi'
|
||||
},
|
||||
content:
|
||||
'Bu hafta sprint planlamasını yaptık. Ekibimizle birlikte yeni özellikleri değerlendirdik. Heyecan verici bir hafta olacak!',
|
||||
createdAt: new Date('2024-10-16T09:00:00'),
|
||||
media: {
|
||||
type: 'poll',
|
||||
poll: {
|
||||
question: 'Hangi özelliği öncelikli olarak geliştirmeliyiz?',
|
||||
options: [
|
||||
{ id: 'p1', text: 'Kullanıcı profilleri', votes: 12 },
|
||||
{ id: 'p2', text: 'Bildirim sistemi', votes: 8 },
|
||||
{ id: 'p3', text: 'Mesajlaşma', votes: 15 },
|
||||
{ id: 'p4', text: 'Raporlama', votes: 5 }
|
||||
],
|
||||
totalVotes: 40,
|
||||
endsAt: new Date('2024-10-20T23:59:59'),
|
||||
userVote: 'p3'
|
||||
}
|
||||
},
|
||||
likes: {
|
||||
count: 18,
|
||||
isLiked: false,
|
||||
users: []
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
id: 'c3',
|
||||
author: {
|
||||
id: 'user4',
|
||||
name: 'Fatma Şahin',
|
||||
avatar: 'https://i.pravatar.cc/150?img=9'
|
||||
},
|
||||
content: 'Mesajlaşma özelliğine kesinlikle ihtiyacımız var!',
|
||||
createdAt: new Date('2024-10-16T10:15:00')
|
||||
}
|
||||
],
|
||||
isOwnPost: true
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
author: {
|
||||
id: 'user5',
|
||||
name: 'Zeynep Arslan',
|
||||
avatar: 'https://i.pravatar.cc/150?img=10',
|
||||
title: 'UI/UX Tasarımcı'
|
||||
},
|
||||
content:
|
||||
'Yeni tasarım sistemimizin ilk prototipini hazırladık! Kullanıcı deneyimini iyileştirmek için çok çalıştık. Geri bildirimlerinizi bekliyorum! 🎨',
|
||||
createdAt: new Date('2024-10-17T14:20:00'),
|
||||
media: {
|
||||
type: 'image',
|
||||
urls: [
|
||||
'https://images.unsplash.com/photo-1561070791-2526d30994b5?w=800&q=80',
|
||||
'https://images.unsplash.com/photo-1586717799252-bd134ad00e26?w=800&q=80',
|
||||
'https://images.unsplash.com/photo-1609921212029-bb5a28e60960?w=800&q=80'
|
||||
]
|
||||
},
|
||||
likes: {
|
||||
count: 42,
|
||||
isLiked: true,
|
||||
users: [
|
||||
{ id: 'user1', name: 'Ahmet Yılmaz', avatar: 'https://i.pravatar.cc/150?img=12' }
|
||||
]
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
id: 'c4',
|
||||
author: {
|
||||
id: 'user6',
|
||||
name: 'Can Öztürk',
|
||||
avatar: 'https://i.pravatar.cc/150?img=11'
|
||||
},
|
||||
content: 'Tasarımlar çok şık! Renk paleti özellikle güzel 😍',
|
||||
createdAt: new Date('2024-10-17T15:00:00')
|
||||
},
|
||||
{
|
||||
id: 'c5',
|
||||
author: {
|
||||
id: 'user7',
|
||||
name: 'Elif Yıldız',
|
||||
avatar: 'https://i.pravatar.cc/150?img=20'
|
||||
},
|
||||
content: 'Dark mode opsiyonu da olacak mı?',
|
||||
createdAt: new Date('2024-10-17T15:30:00')
|
||||
}
|
||||
],
|
||||
isOwnPost: false
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
author: {
|
||||
id: 'user8',
|
||||
name: 'Burak Çelik',
|
||||
avatar: 'https://i.pravatar.cc/150?img=13',
|
||||
title: 'DevOps Mühendisi'
|
||||
},
|
||||
content:
|
||||
'CI/CD pipeline güncellememiz tamamlandı! Deployment süremiz %40 azaldı. Otomasyonun gücü 💪',
|
||||
createdAt: new Date('2024-10-18T08:45:00'),
|
||||
media: {
|
||||
type: 'video',
|
||||
url: 'https://www.w3schools.com/html/mov_bbb.mp4'
|
||||
},
|
||||
likes: {
|
||||
count: 31,
|
||||
isLiked: false,
|
||||
users: []
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
id: 'c6',
|
||||
author: {
|
||||
id: 'user9',
|
||||
name: 'Deniz Koç',
|
||||
avatar: 'https://i.pravatar.cc/150?img=14'
|
||||
},
|
||||
content: 'Harika iş! Detayları paylaşabilir misin?',
|
||||
createdAt: new Date('2024-10-18T09:15:00')
|
||||
}
|
||||
],
|
||||
isOwnPost: false
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
author: {
|
||||
id: 'user10',
|
||||
name: 'Selin Aydın',
|
||||
avatar: 'https://i.pravatar.cc/150?img=15',
|
||||
title: 'İK Müdürü'
|
||||
},
|
||||
content:
|
||||
'Ekip üyelerimize yeni eğitim programımızı duyurmak istiyorum! 🎓 React, TypeScript ve Modern Web Geliştirme konularında kapsamlı bir program hazırladık.',
|
||||
createdAt: new Date('2024-10-14T16:00:00'),
|
||||
likes: {
|
||||
count: 56,
|
||||
isLiked: true,
|
||||
users: []
|
||||
},
|
||||
comments: [
|
||||
{
|
||||
id: 'c7',
|
||||
author: {
|
||||
id: 'user1',
|
||||
name: 'Ahmet Yılmaz',
|
||||
avatar: 'https://i.pravatar.cc/150?img=12'
|
||||
},
|
||||
content: 'Ne zaman başlıyor?',
|
||||
createdAt: new Date('2024-10-14T16:30:00')
|
||||
},
|
||||
{
|
||||
id: 'c8',
|
||||
author: {
|
||||
id: 'user10',
|
||||
name: 'Selin Aydın',
|
||||
avatar: 'https://i.pravatar.cc/150?img=15'
|
||||
},
|
||||
content: 'Gelecek hafta başlıyoruz! Kayıt linki mail ile paylaşılacak.',
|
||||
createdAt: new Date('2024-10-14T17:00:00')
|
||||
}
|
||||
],
|
||||
isOwnPost: false
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
author: {
|
||||
id: 'user11',
|
||||
name: 'Deniz Öztürk',
|
||||
avatar: 'https://i.pravatar.cc/150?img=20',
|
||||
title: 'Proje Yöneticisi'
|
||||
},
|
||||
content: 'Bugün müşteri ile harika bir toplantı yaptık! Yeni projenin detaylarını konuştuk. 🎯',
|
||||
createdAt: new Date('2024-10-17T14:00:00'),
|
||||
location: {
|
||||
id: '4',
|
||||
name: 'Sultanahmet Meydanı',
|
||||
address: 'Sultanahmet Mahallesi, 34122 Fatih/İstanbul',
|
||||
lat: 41.0058,
|
||||
lng: 28.9768,
|
||||
placeId: 'ChIJ7fVVZiy5yhQRzsXXXXXXXXk'
|
||||
},
|
||||
likes: {
|
||||
count: 18,
|
||||
isLiked: false,
|
||||
users: []
|
||||
},
|
||||
comments: [],
|
||||
isOwnPost: false
|
||||
}
|
||||
]
|
||||
|
|
@ -1,15 +1,21 @@
|
|||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { Helmet } from 'react-helmet'
|
||||
import SocialWall from '@/components/intranet/SocialWall'
|
||||
|
||||
const Dashboard = () => {
|
||||
const { translate } = useLocalization()
|
||||
|
||||
return (
|
||||
<Helmet
|
||||
titleTemplate="%s | Sözsoft Kurs Platform"
|
||||
title={translate('::' + 'Dashboard')}
|
||||
defaultTitle="Sözsoft Kurs Platform"
|
||||
></Helmet>
|
||||
<>
|
||||
<Helmet
|
||||
titleTemplate="%s | Sözsoft Kurs Platform"
|
||||
title={translate('::' + 'Dashboard')}
|
||||
defaultTitle="Sözsoft Kurs Platform"
|
||||
/>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<SocialWall />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
1
ui/src/vite-env.d.ts
vendored
1
ui/src/vite-env.d.ts
vendored
|
|
@ -6,6 +6,7 @@ interface ImportMetaEnv {
|
|||
readonly VITE_CDN_URL: string
|
||||
readonly VITE_REACT_APP_VERSION: string
|
||||
readonly VITE_AI_URL: string
|
||||
readonly VITE_GOOGLE_MAPS_API_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
|
|
|||
Loading…
Reference in a new issue