Claude güncellemesi Intranet güncellemesi
This commit is contained in:
parent
5217887bb8
commit
de7e6c1e21
22 changed files with 649 additions and 1516 deletions
|
|
@ -289,7 +289,6 @@ Seed edilen host yöneticisi ile `/login` üzerinden giriş yapılır (kullanıc
|
||||||
| `VITE_CDN_URL` | Dosya/CDN kök adresi. |
|
| `VITE_CDN_URL` | Dosya/CDN kök adresi. |
|
||||||
| `VITE_REACT_APP_VERSION` | `package.json` sürümünden beslenir. |
|
| `VITE_REACT_APP_VERSION` | `package.json` sürümünden beslenir. |
|
||||||
| `VITE_AI_URL` | AI asistanının n8n webhook kökü. |
|
| `VITE_AI_URL` | AI asistanının n8n webhook kökü. |
|
||||||
| `VITE_GOOGLE_MAPS_API_KEY` | Harita bileşenleri için anahtar. |
|
|
||||||
| `VITE_USE_POLLING` | Dosya izlemede polling (WSL/Docker senaryoları). |
|
| `VITE_USE_POLLING` | Dosya izlemede polling (WSL/Docker senaryoları). |
|
||||||
| `VITE_PWA_DEV` | Geliştirmede service worker'ı açar (varsayılan kapalı). |
|
| `VITE_PWA_DEV` | Geliştirmede service worker'ı açar (varsayılan kapalı). |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,6 @@ public class CreateSocialPostInput
|
||||||
{
|
{
|
||||||
public string Content { get; set; } = string.Empty;
|
public string Content { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// JSON string containing location data (name, address, lat, lng, placeId).
|
|
||||||
/// </summary>
|
|
||||||
public string? LocationJson { get; set; }
|
|
||||||
|
|
||||||
public CreateSocialPostMediaInput? Media { get; set; }
|
public CreateSocialPostMediaInput? Media { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,11 @@ public class SocialPostDto : FullAuditedEntityDto<Guid>
|
||||||
public bool IsLiked { get; set; }
|
public bool IsLiked { get; set; }
|
||||||
public bool IsOwnPost { get; set; }
|
public bool IsOwnPost { get; set; }
|
||||||
|
|
||||||
public SocialLocationDto? Location { get; set; }
|
|
||||||
public SocialMediaDto? Media { get; set; }
|
public SocialMediaDto? Media { get; set; }
|
||||||
public List<SocialCommentDto> Comments { get; set; } = [];
|
public List<SocialCommentDto> Comments { get; set; } = [];
|
||||||
public List<SocialLikeDto> Likes { get; set; } = [];
|
public List<SocialLikeDto> Likes { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SocialLocationDto : FullAuditedEntityDto<Guid>
|
|
||||||
{
|
|
||||||
public Guid SocialPostId { get; set; }
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public string? Address { get; set; }
|
|
||||||
public double? Lat { get; set; }
|
|
||||||
public double? Lng { get; set; }
|
|
||||||
public string? PlaceId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SocialMediaDto : FullAuditedEntityDto<Guid>
|
public class SocialMediaDto : FullAuditedEntityDto<Guid>
|
||||||
{
|
{
|
||||||
public Guid SocialPostId { get; set; }
|
public Guid SocialPostId { get; set; }
|
||||||
|
|
|
||||||
|
|
@ -620,7 +620,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
|
||||||
|
|
||||||
// Sonra sadece bu ID'ler için detayları yükle
|
// Sonra sadece bu ID'ler için detayları yükle
|
||||||
var queryable = await _socialPostRepository
|
var queryable = await _socialPostRepository
|
||||||
.WithDetailsAsync(e => e.Location, e => e.Media, e => e.Comments, e => e.Likes);
|
.WithDetailsAsync(e => e.Media, e => e.Comments, e => e.Likes);
|
||||||
|
|
||||||
var socialPosts = await AsyncExecuter.ToListAsync(
|
var socialPosts = await AsyncExecuter.ToListAsync(
|
||||||
queryable
|
queryable
|
||||||
|
|
@ -852,20 +852,6 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
|
||||||
Content = input.Content,
|
Content = input.Content,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(input.LocationJson))
|
|
||||||
{
|
|
||||||
var locData = System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(input.LocationJson);
|
|
||||||
post.Location = new SocialLocation(Guid.NewGuid())
|
|
||||||
{
|
|
||||||
SocialPostId = post.Id,
|
|
||||||
Name = locData.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty,
|
|
||||||
Address = locData.TryGetProperty("address", out var addrProp) ? addrProp.GetString() : null,
|
|
||||||
Lat = locData.TryGetProperty("lat", out var latProp) && latProp.TryGetDouble(out var latVal) ? latVal : null,
|
|
||||||
Lng = locData.TryGetProperty("lng", out var lngProp) && lngProp.TryGetDouble(out var lngVal) ? lngVal : null,
|
|
||||||
PlaceId = locData.TryGetProperty("placeId", out var placeIdProp) ? placeIdProp.GetString() : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (input.Media != null)
|
if (input.Media != null)
|
||||||
{
|
{
|
||||||
var media = new SocialMedia(Guid.NewGuid())
|
var media = new SocialMedia(Guid.NewGuid())
|
||||||
|
|
@ -895,7 +881,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
|
||||||
|
|
||||||
// Reload with full navigation properties for mapping
|
// Reload with full navigation properties for mapping
|
||||||
var queryable = await _socialPostRepository
|
var queryable = await _socialPostRepository
|
||||||
.WithDetailsAsync(e => e.Location, e => e.Media, e => e.Comments, e => e.Likes);
|
.WithDetailsAsync(e => e.Media, e => e.Comments, e => e.Likes);
|
||||||
var savedPost = await AsyncExecuter.FirstOrDefaultAsync(queryable.Where(p => p.Id == post.Id));
|
var savedPost = await AsyncExecuter.FirstOrDefaultAsync(queryable.Where(p => p.Id == post.Id));
|
||||||
|
|
||||||
var dto = ObjectMapper.Map<SocialPost, SocialPostDto>(savedPost!);
|
var dto = ObjectMapper.Map<SocialPost, SocialPostDto>(savedPost!);
|
||||||
|
|
@ -956,7 +942,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
|
||||||
await _socialPostRepository.UpdateAsync(post, autoSave: true);
|
await _socialPostRepository.UpdateAsync(post, autoSave: true);
|
||||||
|
|
||||||
var queryable = await _socialPostRepository
|
var queryable = await _socialPostRepository
|
||||||
.WithDetailsAsync(e => e.Location, e => e.Media, e => e.Comments, e => e.Likes);
|
.WithDetailsAsync(e => e.Media, e => e.Comments, e => e.Likes);
|
||||||
var updated = await AsyncExecuter.FirstOrDefaultAsync(queryable.Where(p => p.Id == id));
|
var updated = await AsyncExecuter.FirstOrDefaultAsync(queryable.Where(p => p.Id == id));
|
||||||
var dto = ObjectMapper.Map<SocialPost, SocialPostDto>(updated!);
|
var dto = ObjectMapper.Map<SocialPost, SocialPostDto>(updated!);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -116,22 +116,6 @@ public partial class SocialPostToSocialPostDtoMapper : MapperBase<SocialPost, So
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
|
||||||
public partial class SocialLocationToSocialLocationDtoMapper : MapperBase<SocialLocation, SocialLocationDto>
|
|
||||||
{
|
|
||||||
public override partial SocialLocationDto Map(SocialLocation source);
|
|
||||||
|
|
||||||
public override partial void Map(SocialLocation source, SocialLocationDto destination);
|
|
||||||
|
|
||||||
public override void BeforeMap(SocialLocation source)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void AfterMap(SocialLocation source, SocialLocationDto destination)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
|
||||||
public partial class SocialMediaToSocialMediaDtoMapper : MapperBase<SocialMedia, SocialMediaDto>
|
public partial class SocialMediaToSocialMediaDtoMapper : MapperBase<SocialMedia, SocialMediaDto>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -12852,120 +12852,6 @@
|
||||||
"tr": "Tüm gönderiler yüklendi",
|
"tr": "Tüm gönderiler yüklendi",
|
||||||
"en": "All posts loaded"
|
"en": "All posts loaded"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps",
|
|
||||||
"en": "Open in Google Maps",
|
|
||||||
"tr": "Google Maps'te aç"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationMap.ClickForDirections",
|
|
||||||
"en": "Click to get directions",
|
|
||||||
"tr": "Yol tarifi almak için tıklayın"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.ApiKeyError",
|
|
||||||
"en": "Google Maps API key not found. Please add VITE_GOOGLE_MAPS_API_KEY to your .env file.",
|
|
||||||
"tr": "Google Maps API anahtarı bulunamadı. Lütfen .env dosyasına VITE_GOOGLE_MAPS_API_KEY ekleyin."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.GoogleMapsLoadError",
|
|
||||||
"en": "Google Maps could not be loaded. Please check your internet connection.",
|
|
||||||
"tr": "Google Maps yüklenemedi. Lütfen internet bağlantınızı kontrol edin."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.NoResults",
|
|
||||||
"en": "No results found",
|
|
||||||
"tr": "Sonuç bulunamadı"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.OverQueryLimit",
|
|
||||||
"en": "Google Places query limit exceeded. Please try again later.",
|
|
||||||
"tr": "Google Places sorgu limiti aşıldı. Lütfen daha sonra tekrar deneyiniz."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.RequestDenied",
|
|
||||||
"en": "Google Places request denied. Please check your API key, billing, or permissions settings.",
|
|
||||||
"tr": "Google Places isteği reddedildi. API key, billing veya yetki ayarlarını kontrol ediniz."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.InvalidRequest",
|
|
||||||
"en": "Invalid location search request. Please check your input and try again.",
|
|
||||||
"tr": "Geçersiz konum arama isteği."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.UnknownError",
|
|
||||||
"en": "Google Places returned a temporary error. Please try again.",
|
|
||||||
"tr": "Google Places geçici bir hata döndürdü. Lütfen tekrar deneyiniz."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SearchFailed",
|
|
||||||
"en": "Location search failed",
|
|
||||||
"tr": "Konum arama başarısız"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SearchError",
|
|
||||||
"en": "An error occurred during location search",
|
|
||||||
"tr": "Konum arama sırasında bir hata oluştu"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.AddLocation",
|
|
||||||
"en": "Add Location",
|
|
||||||
"tr": "Konum Ekle"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SearchPlaceholder",
|
|
||||||
"en": "Search location...",
|
|
||||||
"tr": "Konum ara..."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.LoadingGoogleMaps",
|
|
||||||
"en": "Loading Google Maps...",
|
|
||||||
"tr": "Google Maps yükleniyor..."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SearchingLocations",
|
|
||||||
"en": "Searching locations...",
|
|
||||||
"tr": "Konumlar aranıyor..."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.TypeToSearch",
|
|
||||||
"en": "Type the location you want to search",
|
|
||||||
"tr": "Aramak istediğiniz konumu yazın"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.Example",
|
|
||||||
"en": "e.g. Taksim, Istanbul",
|
|
||||||
"tr": "Örn: Taksim, İstanbul"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.NotFound",
|
|
||||||
"en": "Location not found. Try a different search.",
|
|
||||||
"tr": "Konum bulunamadı. Farklı bir arama yapın."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SelectLocation",
|
|
||||||
"en": "Select a location",
|
|
||||||
"tr": "Bir konum seçin"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Platform.Intranet.SocialWall.MediaManager.AddMedia",
|
"key": "App.Platform.Intranet.SocialWall.MediaManager.AddMedia",
|
||||||
|
|
@ -13068,6 +12954,30 @@
|
||||||
"en": "Write a comment...",
|
"en": "Write a comment...",
|
||||||
"tr": "Yorum yazın..."
|
"tr": "Yorum yazın..."
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Platform.Intranet.SocialWall.PostItem.VoteCount",
|
||||||
|
"en": "{count} votes",
|
||||||
|
"tr": "{count} oy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Platform.Intranet.SocialWall.PostItem.PollEnded",
|
||||||
|
"en": "Ended",
|
||||||
|
"tr": "Sona erdi"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Platform.Intranet.SocialWall.PostItem.PollEndsIn",
|
||||||
|
"en": "ends in {time}",
|
||||||
|
"tr": "{time} içinde bitiyor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"resourceName": "Platform",
|
||||||
|
"key": "App.Platform.Intranet.SocialWall.PostItem.Fullscreen",
|
||||||
|
"en": "Fullscreen",
|
||||||
|
"tr": "Tam ekran"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Platform.Intranet.Widgets.PriorityTasks.Title",
|
"key": "App.Platform.Intranet.Widgets.PriorityTasks.Title",
|
||||||
|
|
@ -13248,18 +13158,6 @@
|
||||||
"tr": "Yanıtlar",
|
"tr": "Yanıtlar",
|
||||||
"en": "Responses"
|
"en": "Responses"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.Widgets.ActiveSurveys.Duration",
|
|
||||||
"tr": "Süre",
|
|
||||||
"en": "Duration"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.Widgets.ActiveSurveys.CompletionRate",
|
|
||||||
"tr": "Tamamlanma oranı",
|
|
||||||
"en": "Completion Rate"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Platform.Intranet.Widgets.ActiveSurveys.FillSurvey",
|
"key": "App.Platform.Intranet.Widgets.ActiveSurveys.FillSurvey",
|
||||||
|
|
@ -13644,12 +13542,6 @@
|
||||||
"tr": "Tüm medyaları kaldır",
|
"tr": "Tüm medyaları kaldır",
|
||||||
"en": "Remove all media"
|
"en": "Remove all media"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.CreatePost.RemoveLocationTitle",
|
|
||||||
"tr": "Konumu kaldır",
|
|
||||||
"en": "Remove location"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Platform.Intranet.SocialWall.CreatePost.Poll",
|
"key": "App.Platform.Intranet.SocialWall.CreatePost.Poll",
|
||||||
|
|
@ -13704,18 +13596,6 @@
|
||||||
"tr": "Emoji ekle",
|
"tr": "Emoji ekle",
|
||||||
"en": "Add emoji"
|
"en": "Add emoji"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.CreatePost.EditLocationTitle",
|
|
||||||
"tr": "Konumu değiştir",
|
|
||||||
"en": "Edit location"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"resourceName": "Platform",
|
|
||||||
"key": "App.Platform.Intranet.SocialWall.CreatePost.AddLocationTitle",
|
|
||||||
"tr": "Konum ekle",
|
|
||||||
"en": "Add location"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"resourceName": "Platform",
|
"resourceName": "Platform",
|
||||||
"key": "App.Platform.Intranet.SocialWall.CreatePost.Submit",
|
"key": "App.Platform.Intranet.SocialWall.CreatePost.Submit",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
|
||||||
namespace Sozsoft.Platform.Migrations
|
namespace Sozsoft.Platform.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(PlatformDbContext))]
|
[DbContext(typeof(PlatformDbContext))]
|
||||||
[Migration("20260808210009_Initial")]
|
[Migration("20260811131124_Initial")]
|
||||||
partial class Initial
|
partial class Initial
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
@ -63,7 +63,6 @@ public class TenantSeederDto
|
||||||
public List<SurveyQuestionSeedDto> SurveyQuestions { get; set; }
|
public List<SurveyQuestionSeedDto> SurveyQuestions { get; set; }
|
||||||
public List<SurveyQuestionOptionSeedDto> SurveyQuestionOptions { get; set; }
|
public List<SurveyQuestionOptionSeedDto> SurveyQuestionOptions { get; set; }
|
||||||
public List<SocialPostSeedDto> SocialPosts { get; set; }
|
public List<SocialPostSeedDto> SocialPosts { get; set; }
|
||||||
public List<SocialLocationSeedDto> SocialLocations { get; set; }
|
|
||||||
public List<SocialMediaSeedDto> SocialMedias { get; set; }
|
public List<SocialMediaSeedDto> SocialMedias { get; set; }
|
||||||
public List<SocialPollOptionSeedDto> SocialPollOptions { get; set; }
|
public List<SocialPollOptionSeedDto> SocialPollOptions { get; set; }
|
||||||
public List<SocialCommentSeedDto> SocialComments { get; set; }
|
public List<SocialCommentSeedDto> SocialComments { get; set; }
|
||||||
|
|
@ -97,16 +96,6 @@ public class SocialPostSeedDto
|
||||||
public bool IsOwnPost { get; set; }
|
public bool IsOwnPost { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SocialLocationSeedDto
|
|
||||||
{
|
|
||||||
public string PostContent { get; set; }
|
|
||||||
public string Name { get; set; }
|
|
||||||
public string Address { get; set; }
|
|
||||||
public double? Lat { get; set; }
|
|
||||||
public double? Lng { get; set; }
|
|
||||||
public string PlaceId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SocialMediaSeedDto
|
public class SocialMediaSeedDto
|
||||||
{
|
{
|
||||||
public string PostContent { get; set; }
|
public string PostContent { get; set; }
|
||||||
|
|
@ -521,7 +510,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
private readonly IRepository<SurveyQuestion, Guid> _surveyQuestionRepository;
|
private readonly IRepository<SurveyQuestion, Guid> _surveyQuestionRepository;
|
||||||
private readonly IRepository<SurveyQuestionOption, Guid> _surveyQuestionOptionRepository;
|
private readonly IRepository<SurveyQuestionOption, Guid> _surveyQuestionOptionRepository;
|
||||||
private readonly IRepository<SocialPost, Guid> _socialPostRepository;
|
private readonly IRepository<SocialPost, Guid> _socialPostRepository;
|
||||||
private readonly IRepository<SocialLocation, Guid> _socialLocationRepository;
|
|
||||||
private readonly IRepository<SocialMedia, Guid> _socialMediaRepository;
|
private readonly IRepository<SocialMedia, Guid> _socialMediaRepository;
|
||||||
private readonly IRepository<SocialPollOption, Guid> _socialPollOptionRepository;
|
private readonly IRepository<SocialPollOption, Guid> _socialPollOptionRepository;
|
||||||
private readonly IRepository<SocialComment, Guid> _socialCommentRepository;
|
private readonly IRepository<SocialComment, Guid> _socialCommentRepository;
|
||||||
|
|
@ -571,7 +559,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
IRepository<SurveyQuestion, Guid> surveyQuestionRepository,
|
IRepository<SurveyQuestion, Guid> surveyQuestionRepository,
|
||||||
IRepository<SurveyQuestionOption, Guid> surveyQuestionOptionRepository,
|
IRepository<SurveyQuestionOption, Guid> surveyQuestionOptionRepository,
|
||||||
IRepository<SocialPost, Guid> socialPostRepository,
|
IRepository<SocialPost, Guid> socialPostRepository,
|
||||||
IRepository<SocialLocation, Guid> socialLocationRepository,
|
|
||||||
IRepository<SocialMedia, Guid> socialMediaRepository,
|
IRepository<SocialMedia, Guid> socialMediaRepository,
|
||||||
IRepository<SocialPollOption, Guid> socialPollOptionRepository,
|
IRepository<SocialPollOption, Guid> socialPollOptionRepository,
|
||||||
IRepository<SocialComment, Guid> socialCommentRepository,
|
IRepository<SocialComment, Guid> socialCommentRepository,
|
||||||
|
|
@ -620,7 +607,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
_surveyQuestionRepository = surveyQuestionRepository;
|
_surveyQuestionRepository = surveyQuestionRepository;
|
||||||
_surveyQuestionOptionRepository = surveyQuestionOptionRepository;
|
_surveyQuestionOptionRepository = surveyQuestionOptionRepository;
|
||||||
_socialPostRepository = socialPostRepository;
|
_socialPostRepository = socialPostRepository;
|
||||||
_socialLocationRepository = socialLocationRepository;
|
|
||||||
_socialMediaRepository = socialMediaRepository;
|
_socialMediaRepository = socialMediaRepository;
|
||||||
_socialPollOptionRepository = socialPollOptionRepository;
|
_socialPollOptionRepository = socialPollOptionRepository;
|
||||||
_socialCommentRepository = socialCommentRepository;
|
_socialCommentRepository = socialCommentRepository;
|
||||||
|
|
@ -1162,28 +1148,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
||||||
}, autoSave: true);
|
}, autoSave: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var item in items.SocialLocations)
|
|
||||||
{
|
|
||||||
var post = await _socialPostRepository.FirstOrDefaultAsync(x => x.Content == item.PostContent);
|
|
||||||
|
|
||||||
if (post == null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var exists = await _socialLocationRepository.AnyAsync(x => x.SocialPostId == post.Id && x.Name == item.Name);
|
|
||||||
if (exists)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
await _socialLocationRepository.InsertAsync(new SocialLocation(Guid.NewGuid())
|
|
||||||
{
|
|
||||||
SocialPostId = post != null ? post.Id : Guid.Empty,
|
|
||||||
Name = item.Name,
|
|
||||||
Address = item.Address,
|
|
||||||
Lat = item.Lat,
|
|
||||||
Lng = item.Lng,
|
|
||||||
PlaceId = item.PlaceId
|
|
||||||
}, autoSave: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var item in items.SocialMedias)
|
foreach (var item in items.SocialMedias)
|
||||||
{
|
{
|
||||||
var post = await _socialPostRepository.FirstOrDefaultAsync(x => x.Content == item.PostContent);
|
var post = await _socialPostRepository.FirstOrDefaultAsync(x => x.Content == item.PostContent);
|
||||||
|
|
|
||||||
18
ui/.env
18
ui/.env
|
|
@ -2,21 +2,3 @@ VITE_API_URL='https://localhost:44344'
|
||||||
VITE_CDN_URL='http://localhost:4005'
|
VITE_CDN_URL='http://localhost:4005'
|
||||||
VITE_REACT_APP_VERSION=$npm_package_version
|
VITE_REACT_APP_VERSION=$npm_package_version
|
||||||
VITE_AI_URL='https://ai.sozsoft.com/webhook/'
|
VITE_AI_URL='https://ai.sozsoft.com/webhook/'
|
||||||
VITE_GOOGLE_MAPS_API_KEY='AIzaSyAefS2rvF-xwq7OHpZ27UYxXPbMo6OwACc'
|
|
||||||
# Google Cloud Console’da:
|
|
||||||
|
|
||||||
# APIs & Services > Enabled APIs & services bölümüne gir.
|
|
||||||
# Şunların aktif olduğundan emin ol:
|
|
||||||
# Maps JavaScript API
|
|
||||||
# Places API
|
|
||||||
|
|
||||||
# Eğer ileride koordinat/adres çözümleme yapıyorsan ayrıca:
|
|
||||||
|
|
||||||
# Geocoding API
|
|
||||||
# APIs & Services > Credentials > API Key içine gir.
|
|
||||||
# API restrictions bölümünde:
|
|
||||||
|
|
||||||
# Eğer Restrict key seçiliyse, izinli API listesine şunları ekle:
|
|
||||||
|
|
||||||
# Maps JavaScript API
|
|
||||||
# Places API
|
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,6 @@ export interface SocialPostDto {
|
||||||
id: string
|
id: string
|
||||||
user: UserInfoViewModel
|
user: UserInfoViewModel
|
||||||
content: string
|
content: string
|
||||||
locationJson?: string
|
|
||||||
media?: SocialMediaDto
|
media?: SocialMediaDto
|
||||||
likeCount: number
|
likeCount: number
|
||||||
isLiked: boolean
|
isLiked: boolean
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ import apiService, { Config } from './api.service'
|
||||||
|
|
||||||
export interface CreateSocialPostInput {
|
export interface CreateSocialPostInput {
|
||||||
content: string
|
content: string
|
||||||
locationJson?: string
|
|
||||||
media?: {
|
media?: {
|
||||||
type: 'image' | 'video' | 'poll'
|
type: 'image' | 'video' | 'poll'
|
||||||
urls?: string[]
|
urls?: string[]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import useLocale from '@/utils/hooks/useLocale'
|
import useLocale from '@/utils/hooks/useLocale'
|
||||||
import { currentLocalDate } from '@/utils/dateUtils'
|
import { currentLocalDate } from '@/utils/dateUtils'
|
||||||
import { useStoreActions, useStoreState } from '@/store/store'
|
import { useStoreActions, useStoreState } from '@/store/store'
|
||||||
import type { DashboardLayout } from '@/store/admin.model'
|
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import { LuX } from 'react-icons/lu'
|
import { LuX } from 'react-icons/lu'
|
||||||
import type { DashboardWidgetColumn, DashboardWidgetDefinition } from './dashboardWidget'
|
import type { DashboardWidgetColumn, DashboardWidgetDefinition } from './dashboardWidget'
|
||||||
|
|
@ -73,8 +72,6 @@ const layoutPresets = [
|
||||||
},
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type DashboardLayoutId = DashboardLayout
|
|
||||||
|
|
||||||
const columnSpanClasses: Record<number, string> = {
|
const columnSpanClasses: Record<number, string> = {
|
||||||
2: 'lg:col-span-2',
|
2: 'lg:col-span-2',
|
||||||
3: 'lg:col-span-3',
|
3: 'lg:col-span-3',
|
||||||
|
|
@ -105,10 +102,14 @@ const IntranetDashboard: React.FC = () => {
|
||||||
const currentLocale = useLocale()
|
const currentLocale = useLocale()
|
||||||
|
|
||||||
const fetchIntranetDashboard = async () => {
|
const fetchIntranetDashboard = async () => {
|
||||||
|
try {
|
||||||
const dashboard = await intranetService.getDashboard()
|
const dashboard = await intranetService.getDashboard()
|
||||||
if (dashboard.data) {
|
if (dashboard.data) {
|
||||||
setIntranetDashboard(dashboard.data)
|
setIntranetDashboard(dashboard.data)
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// hata apiService tarafından ele alınıyor
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -199,35 +200,28 @@ const IntranetDashboard: React.FC = () => {
|
||||||
if (!grantedPolicies) return
|
if (!grantedPolicies) return
|
||||||
|
|
||||||
const hasSavedOrder = dashboardColumns.some((column) => widgetOrder[column].length > 0)
|
const hasSavedOrder = dashboardColumns.some((column) => widgetOrder[column].length > 0)
|
||||||
if (hasSavedOrder) {
|
if (!hasSavedOrder) {
|
||||||
try {
|
initializeDefaultOrder()
|
||||||
const parsed = widgetOrder
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kayıtlı sıralamayı tekilleştir ve henüz yerleştirilmemiş widget'ları ekle
|
||||||
const order: Record<DashboardWidgetColumn, string[]> = {
|
const order: Record<DashboardWidgetColumn, string[]> = {
|
||||||
left: [...new Set((parsed.left || []) as string[])],
|
left: [...new Set(widgetOrder.left || [])],
|
||||||
center: [...new Set((parsed.center || []) as string[])],
|
center: [...new Set(widgetOrder.center || [])],
|
||||||
right: [...new Set((parsed.right || []) as string[])],
|
right: [...new Set(widgetOrder.right || [])],
|
||||||
}
|
}
|
||||||
|
|
||||||
const allAssigned = new Set([...order.left, ...order.center, ...order.right])
|
const allAssigned = new Set([...order.left, ...order.center, ...order.right])
|
||||||
dashboardWidgets.forEach((w) => {
|
dashboardWidgets.forEach((widget) => {
|
||||||
if (!allAssigned.has(w.id) && checkPermission(w.permission)) {
|
if (!allAssigned.has(widget.id) && checkPermission(widget.permission)) {
|
||||||
order[w.column as keyof typeof order].push(w.id)
|
order[widget.column].push(widget.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
setWidgetOrder(order)
|
setWidgetOrder(order)
|
||||||
} catch {
|
|
||||||
initializeDefaultOrder()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
initializeDefaultOrder()
|
|
||||||
}
|
|
||||||
}, [grantedPolicies])
|
}, [grantedPolicies])
|
||||||
|
|
||||||
const saveWidgetOrder = (newOrder: Record<DashboardWidgetColumn, string[]>) => {
|
|
||||||
setWidgetOrder(newOrder)
|
|
||||||
}
|
|
||||||
|
|
||||||
const setWidgetVisibility = (widgetId: string, visible: boolean) => {
|
const setWidgetVisibility = (widgetId: string, visible: boolean) => {
|
||||||
const next = visible
|
const next = visible
|
||||||
? hiddenWidgetIds.filter((id) => id !== widgetId)
|
? hiddenWidgetIds.filter((id) => id !== widgetId)
|
||||||
|
|
@ -235,10 +229,6 @@ const IntranetDashboard: React.FC = () => {
|
||||||
setHiddenWidgetIds(next)
|
setHiddenWidgetIds(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectLayout = (layoutId: DashboardLayoutId) => {
|
|
||||||
setDashboardLayout(layoutId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeLayout =
|
const activeLayout =
|
||||||
layoutPresets.find((layout) => layout.id === selectedLayout) || layoutPresets[0]
|
layoutPresets.find((layout) => layout.id === selectedLayout) || layoutPresets[0]
|
||||||
const hiddenWidgets = dashboardWidgets.filter(
|
const hiddenWidgets = dashboardWidgets.filter(
|
||||||
|
|
@ -295,7 +285,7 @@ const IntranetDashboard: React.FC = () => {
|
||||||
newOrder[targetColumn].push(widgetId)
|
newOrder[targetColumn].push(widgetId)
|
||||||
}
|
}
|
||||||
|
|
||||||
saveWidgetOrder(newOrder)
|
setWidgetOrder(newOrder)
|
||||||
setDragState({ draggedId: null, targetColumn: null, targetIndex: null })
|
setDragState({ draggedId: null, targetColumn: null, targetIndex: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -532,7 +522,7 @@ const IntranetDashboard: React.FC = () => {
|
||||||
type="button"
|
type="button"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
onClick={() => selectLayout(layout.id)}
|
onClick={() => setDashboardLayout(layout.id)}
|
||||||
className={`!h-8 !rounded-md !px-2 ${selectedLayout === layout.id ? '!bg-blue-600 text-white hover:!bg-blue-600' : '!bg-gray-100 text-gray-600 hover:!bg-gray-200 dark:!bg-gray-700 dark:text-gray-200'}`}
|
className={`!h-8 !rounded-md !px-2 ${selectedLayout === layout.id ? '!bg-blue-600 text-white hover:!bg-blue-600' : '!bg-gray-100 text-gray-600 hover:!bg-gray-200 dark:!bg-gray-700 dark:text-gray-200'}`}
|
||||||
title={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
|
title={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
|
||||||
aria-label={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
|
aria-label={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import React, { useState, useRef } from 'react'
|
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import type { EmojiClickData, Theme } from 'emoji-picker-react'
|
import type { EmojiClickData, Theme } from 'emoji-picker-react'
|
||||||
import { FaChartBar, FaSmile, FaTimes, FaImages, FaMapMarkerAlt } from 'react-icons/fa'
|
import { FaChartBar, FaSmile, FaTimes, FaImages } from 'react-icons/fa'
|
||||||
import MediaManager from './MediaManager'
|
import MediaManager from './MediaManager'
|
||||||
import LocationPicker from './LocationPicker'
|
|
||||||
import { SocialMediaDto } from '@/proxy/intranet/models'
|
import { SocialMediaDto } from '@/proxy/intranet/models'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import { useStoreState } from '@/store/store'
|
import { useStoreState } from '@/store/store'
|
||||||
import { Avatar, Button } from '@/components/ui'
|
import { Avatar, Button } from '@/components/ui'
|
||||||
import { AVATAR_URL } from '@/constants/app.constant'
|
import { AVATAR_URL } from '@/constants/app.constant'
|
||||||
|
|
||||||
|
const MAX_POLL_OPTIONS = 6
|
||||||
|
const MIN_POLL_OPTIONS = 2
|
||||||
|
|
||||||
interface CreatePostProps {
|
interface CreatePostProps {
|
||||||
onCreatePost: (post: {
|
onCreatePost: (post: {
|
||||||
content: string
|
content: string
|
||||||
location?: string
|
|
||||||
media?: {
|
media?: {
|
||||||
type: 'mixed' | 'poll'
|
type: 'mixed' | 'poll'
|
||||||
mediaItems?: SocialMediaDto[]
|
mediaItems?: SocialMediaDto[]
|
||||||
|
|
@ -28,103 +29,97 @@ interface CreatePostProps {
|
||||||
|
|
||||||
const EmojiPicker = React.lazy(() => import('emoji-picker-react'))
|
const EmojiPicker = React.lazy(() => import('emoji-picker-react'))
|
||||||
|
|
||||||
|
/** Bir sonraki tıklama hedefini temizleyen link/plain buton stilleri */
|
||||||
|
const LINK_BUTTON_CLASS =
|
||||||
|
'!h-auto !rounded-none !px-0 !py-0 text-sm font-medium hover:!bg-transparent active:!bg-transparent focus:!bg-transparent'
|
||||||
|
const TOOLBAR_BUTTON_CLASS = '!h-9 !w-9 !px-0 transition-colors'
|
||||||
|
const TOOLBAR_ACTIVE_CLASS = '!bg-blue-100 text-blue-600 dark:!bg-blue-900 dark:text-blue-400'
|
||||||
|
const TOOLBAR_IDLE_CLASS =
|
||||||
|
'text-gray-600 hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700'
|
||||||
|
|
||||||
const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [mediaType, setMediaType] = useState<'media' | 'poll' | null>(null)
|
const [mediaType, setMediaType] = useState<'media' | 'poll' | null>(null)
|
||||||
const [mediaItems, setMediaItems] = useState<SocialMediaDto[]>([])
|
const [mediaItems, setMediaItems] = useState<SocialMediaDto[]>([])
|
||||||
const [location, setLocation] = useState<string | null>(null)
|
|
||||||
const [pollQuestion, setPollQuestion] = useState('')
|
const [pollQuestion, setPollQuestion] = useState('')
|
||||||
const [pollOptions, setPollOptions] = useState(['', ''])
|
const [pollOptions, setPollOptions] = useState(['', ''])
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||||
const [showMediaManager, setShowMediaManager] = useState(false)
|
const [showMediaManager, setShowMediaManager] = useState(false)
|
||||||
const [showLocationPicker, setShowLocationPicker] = useState(false)
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
const emojiContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const { user, tenant } = useStoreState((state) => state.auth)
|
const { user, tenant } = useStoreState((state) => state.auth)
|
||||||
const theme = useStoreState((state) => state.theme)
|
const theme = useStoreState((state) => state.theme)
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const filledPollOptions = useMemo(
|
||||||
e.preventDefault()
|
() => pollOptions.map((option) => option.trim()).filter(Boolean),
|
||||||
|
[pollOptions],
|
||||||
|
)
|
||||||
|
const hasMedia = mediaType === 'media' && mediaItems.length > 0
|
||||||
|
const hasPoll =
|
||||||
|
mediaType === 'poll' && !!pollQuestion.trim() && filledPollOptions.length >= MIN_POLL_OPTIONS
|
||||||
|
const canSubmit = !!content.trim() || hasMedia || hasPoll
|
||||||
|
|
||||||
if (!content.trim() && mediaItems.length === 0 && !mediaType) return
|
const resetForm = () => {
|
||||||
|
|
||||||
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('')
|
setContent('')
|
||||||
setMediaType(null)
|
setMediaType(null)
|
||||||
setMediaItems([])
|
setMediaItems([])
|
||||||
setLocation(null)
|
|
||||||
setPollQuestion('')
|
setPollQuestion('')
|
||||||
setPollOptions(['', ''])
|
setPollOptions(['', ''])
|
||||||
setIsExpanded(false)
|
setIsExpanded(false)
|
||||||
setShowEmojiPicker(false)
|
setShowEmojiPicker(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!canSubmit) return
|
||||||
|
|
||||||
|
let media: Parameters<CreatePostProps['onCreatePost']>[0]['media']
|
||||||
|
|
||||||
|
if (hasMedia) {
|
||||||
|
media = { type: 'mixed', mediaItems }
|
||||||
|
} else if (hasPoll) {
|
||||||
|
media = {
|
||||||
|
type: 'poll',
|
||||||
|
poll: {
|
||||||
|
question: pollQuestion.trim(),
|
||||||
|
options: filledPollOptions.map((text) => ({ text })),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCreatePost({ content, media })
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
|
||||||
const handleEmojiClick = (emojiData: EmojiClickData) => {
|
const handleEmojiClick = (emojiData: EmojiClickData) => {
|
||||||
const emoji = emojiData.emoji
|
|
||||||
const textarea = textareaRef.current
|
const textarea = textareaRef.current
|
||||||
if (!textarea) return
|
if (!textarea) return
|
||||||
|
|
||||||
|
const { emoji } = emojiData
|
||||||
const start = textarea.selectionStart
|
const start = textarea.selectionStart
|
||||||
const end = textarea.selectionEnd
|
const end = textarea.selectionEnd
|
||||||
const text = content
|
|
||||||
const before = text.substring(0, start)
|
|
||||||
const after = text.substring(end)
|
|
||||||
|
|
||||||
setContent(before + emoji + after)
|
setContent((prev) => prev.substring(0, start) + emoji + prev.substring(end))
|
||||||
|
|
||||||
// Set cursor position after emoji
|
// Emoji eklendikten sonra imleci emojinin sonuna taşı
|
||||||
setTimeout(() => {
|
requestAnimationFrame(() => {
|
||||||
textarea.selectionStart = textarea.selectionEnd = start + emoji.length
|
textarea.selectionStart = textarea.selectionEnd = start + emoji.length
|
||||||
textarea.focus()
|
textarea.focus()
|
||||||
}, 0)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const addPollOption = () => {
|
const addPollOption = () =>
|
||||||
if (pollOptions.length < 6) {
|
setPollOptions((prev) => (prev.length < MAX_POLL_OPTIONS ? [...prev, ''] : prev))
|
||||||
setPollOptions([...pollOptions, ''])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removePollOption = (index: number) => {
|
const removePollOption = (index: number) =>
|
||||||
if (pollOptions.length > 2) {
|
setPollOptions((prev) =>
|
||||||
setPollOptions(pollOptions.filter((_, i) => i !== index))
|
prev.length > MIN_POLL_OPTIONS ? prev.filter((_, i) => i !== index) : prev,
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const updatePollOption = (index: number, value: string) => {
|
const updatePollOption = (index: number, value: string) =>
|
||||||
const newOptions = [...pollOptions]
|
setPollOptions((prev) => prev.map((option, i) => (i === index ? value : option)))
|
||||||
newOptions[index] = value
|
|
||||||
setPollOptions(newOptions)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearMedia = () => {
|
const clearMedia = () => {
|
||||||
setMediaType(null)
|
setMediaType(null)
|
||||||
|
|
@ -135,24 +130,21 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
|
|
||||||
const removeMediaItem = (id: string | undefined) => {
|
const removeMediaItem = (id: string | undefined) => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
setMediaItems(mediaItems.filter((m) => m.id !== id))
|
setMediaItems((prev) => prev.filter((m) => m.id !== id))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close emoji picker when clicking outside
|
// Emoji seçici dışına tıklanınca kapat (tetikleyici buton da kapsayıcının içinde)
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!showEmojiPicker) return
|
||||||
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
|
if (!emojiContainerRef.current?.contains(event.target as Node)) {
|
||||||
setShowEmojiPicker(false)
|
setShowEmojiPicker(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showEmojiPicker) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside)
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
}
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside)
|
|
||||||
}
|
|
||||||
}, [showEmojiPicker])
|
}, [showEmojiPicker])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -179,7 +171,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
|
|
||||||
{/* Media Preview */}
|
{/* Media Preview */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mediaType === 'media' && mediaItems.length > 0 && (
|
{hasMedia && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, height: 0 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
animate={{ opacity: 1, height: 'auto' }}
|
animate={{ opacity: 1, height: 'auto' }}
|
||||||
|
|
@ -193,12 +185,10 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
</h4>
|
</h4>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={clearMedia}
|
||||||
clearMedia()
|
|
||||||
}}
|
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
className="!h-auto !rounded-none !px-0 !py-0 text-sm font-medium text-red-600 hover:!bg-transparent hover:text-red-700 active:!bg-transparent focus:!bg-transparent"
|
className={classNames(LINK_BUTTON_CLASS, 'text-red-600 hover:text-red-700')}
|
||||||
title={translate(
|
title={translate(
|
||||||
'::App.Platform.Intranet.SocialWall.CreatePost.RemoveAllMediaTitle',
|
'::App.Platform.Intranet.SocialWall.CreatePost.RemoveAllMediaTitle',
|
||||||
)}
|
)}
|
||||||
|
|
@ -220,10 +210,12 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
<video
|
<video
|
||||||
src={item.urls?.[0]}
|
src={item.urls?.[0]}
|
||||||
className="w-full h-full object-cover rounded-lg"
|
className="w-full h-full object-cover rounded-lg"
|
||||||
|
muted
|
||||||
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<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-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 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -256,46 +248,6 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
</motion.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">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.CreatePost.Location')}
|
|
||||||
</h4>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setLocation(null)}
|
|
||||||
variant="plain"
|
|
||||||
shape="none"
|
|
||||||
className="!h-auto !rounded-none !px-0 !py-0 text-sm font-medium text-red-600 hover:!bg-transparent hover:text-red-700 active:!bg-transparent focus:!bg-transparent"
|
|
||||||
title={translate(
|
|
||||||
'::App.Platform.Intranet.SocialWall.CreatePost.RemoveLocationTitle',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{translate('::Cancel')}
|
|
||||||
</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">
|
|
||||||
<FaMapMarkerAlt 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">
|
|
||||||
{JSON.parse(location).name}
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
|
||||||
{JSON.parse(location).address}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mediaType === 'poll' && (
|
{mediaType === 'poll' && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, height: 0 }}
|
initial={{ opacity: 0, height: 0 }}
|
||||||
|
|
@ -309,12 +261,10 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
</h4>
|
</h4>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={clearMedia}
|
||||||
clearMedia()
|
|
||||||
}}
|
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
className="!h-auto !rounded-none !px-0 !py-0 text-sm font-medium text-red-600 hover:!bg-transparent hover:text-red-700 active:!bg-transparent focus:!bg-transparent"
|
className={classNames(LINK_BUTTON_CLASS, 'text-red-600 hover:text-red-700')}
|
||||||
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.RemovePollTitle')}
|
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.RemovePollTitle')}
|
||||||
>
|
>
|
||||||
{translate('::Cancel')}
|
{translate('::Cancel')}
|
||||||
|
|
@ -331,6 +281,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
/>
|
/>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{pollOptions.map((option, index) => (
|
{pollOptions.map((option, index) => (
|
||||||
|
// Seçenekler stabil bir kimliğe sahip değil; sıra indeksi anahtar olarak kullanılıyor
|
||||||
<div key={index} className="flex gap-2">
|
<div key={index} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|
@ -341,7 +292,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
)}
|
)}
|
||||||
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"
|
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 && (
|
{pollOptions.length > MIN_POLL_OPTIONS && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removePollOption(index)}
|
onClick={() => removePollOption(index)}
|
||||||
|
|
@ -354,13 +305,16 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{pollOptions.length < 6 && (
|
{pollOptions.length < MAX_POLL_OPTIONS && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addPollOption}
|
onClick={addPollOption}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
className="mt-2 !h-auto !rounded-none !px-0 !py-0 text-sm font-medium text-blue-600 hover:!bg-transparent hover:text-blue-700 active:!bg-transparent focus:!bg-transparent"
|
className={classNames(
|
||||||
|
LINK_BUTTON_CLASS,
|
||||||
|
'mt-2 text-blue-600 hover:text-blue-700',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
+ {translate('::App.Platform.Intranet.SocialWall.CreatePost.AddOption')}
|
+ {translate('::App.Platform.Intranet.SocialWall.CreatePost.AddOption')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -382,23 +336,19 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (mediaType === 'media' && mediaItems.length > 0) {
|
// Farklı bir tip seçiliyse önce temizle, sonra medya modunu aç
|
||||||
// Eğer zaten medya varsa, yöneticiyi aç
|
if (mediaType !== 'media') {
|
||||||
setShowMediaManager(true)
|
|
||||||
} else {
|
|
||||||
// Başka bir tip seçiliyse temizle ve medya modunu aç
|
|
||||||
clearMedia()
|
clearMedia()
|
||||||
setMediaType('media')
|
setMediaType('media')
|
||||||
setShowMediaManager(true)
|
|
||||||
}
|
}
|
||||||
|
setShowMediaManager(true)
|
||||||
}}
|
}}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'relative !h-9 !w-9 !px-0 overflow-visible transition-colors',
|
TOOLBAR_BUTTON_CLASS,
|
||||||
mediaType === 'media'
|
'relative overflow-visible',
|
||||||
? '!bg-blue-100 text-blue-600 dark:!bg-blue-900 dark:text-blue-400'
|
mediaType === 'media' ? TOOLBAR_ACTIVE_CLASS : TOOLBAR_IDLE_CLASS,
|
||||||
: 'text-gray-600 hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700',
|
|
||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
mediaType === 'media'
|
mediaType === 'media'
|
||||||
|
|
@ -409,7 +359,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
<span className="absolute inset-0 flex items-center justify-center">
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
<FaImages className="h-5 w-5" />
|
<FaImages className="h-5 w-5" />
|
||||||
</span>
|
</span>
|
||||||
{mediaType === 'media' && mediaItems.length > 0 && (
|
{hasMedia && (
|
||||||
<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">
|
<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}
|
{mediaItems.length}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -418,20 +368,19 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Başka bir tip seçiliyse temizle
|
if (mediaType === 'poll') {
|
||||||
if (mediaType !== 'poll') {
|
|
||||||
clearMedia()
|
clearMedia()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
setMediaType(mediaType === 'poll' ? null : 'poll')
|
clearMedia()
|
||||||
|
setMediaType('poll')
|
||||||
}}
|
}}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<FaChartBar className="h-5 w-5" />}
|
icon={<FaChartBar className="h-5 w-5" />}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
'!h-9 !w-9 !px-0 transition-colors',
|
TOOLBAR_BUTTON_CLASS,
|
||||||
mediaType === 'poll'
|
mediaType === 'poll' ? TOOLBAR_ACTIVE_CLASS : TOOLBAR_IDLE_CLASS,
|
||||||
? '!bg-blue-100 text-blue-600 dark:!bg-blue-900 dark:text-blue-400'
|
|
||||||
: 'text-gray-600 hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700',
|
|
||||||
)}
|
)}
|
||||||
title={
|
title={
|
||||||
mediaType === 'poll'
|
mediaType === 'poll'
|
||||||
|
|
@ -439,41 +388,26 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
: translate('::App.Platform.Intranet.SocialWall.CreatePost.AddPollTitle')
|
: translate('::App.Platform.Intranet.SocialWall.CreatePost.AddPollTitle')
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Emoji Picker */}
|
||||||
|
<div ref={emojiContainerRef} className="relative">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
onClick={() => setShowEmojiPicker((prev) => !prev)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
icon={<FaSmile className="h-5 w-5" />}
|
icon={<FaSmile className="h-5 w-5" />}
|
||||||
className="!h-9 !w-9 !px-0 text-gray-600 transition-colors hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700"
|
className={classNames(TOOLBAR_BUTTON_CLASS, TOOLBAR_IDLE_CLASS)}
|
||||||
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.AddEmojiTitle')}
|
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.AddEmojiTitle')}
|
||||||
|
aria-expanded={showEmojiPicker}
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowLocationPicker(true)}
|
|
||||||
variant="plain"
|
|
||||||
shape="circle"
|
|
||||||
icon={<FaMapMarkerAlt className="h-5 w-5" />}
|
|
||||||
className={classNames(
|
|
||||||
'!h-9 !w-9 !px-0 transition-colors',
|
|
||||||
location
|
|
||||||
? '!bg-blue-100 text-blue-600 dark:!bg-blue-900 dark:text-blue-400'
|
|
||||||
: 'text-gray-600 hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700',
|
|
||||||
)}
|
|
||||||
title={
|
|
||||||
location
|
|
||||||
? translate('::App.Platform.Intranet.SocialWall.CreatePost.EditLocationTitle')
|
|
||||||
: translate('::App.Platform.Intranet.SocialWall.CreatePost.AddLocationTitle')
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Emoji Picker */}
|
|
||||||
{showEmojiPicker && (
|
{showEmojiPicker && (
|
||||||
<div
|
<div className="absolute bottom-full left-0 z-50 mb-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2">
|
||||||
ref={emojiPickerRef}
|
<React.Suspense
|
||||||
className="absolute bottom-6 left-0 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2"
|
fallback={
|
||||||
|
<div className="h-[350px] w-[350px] max-w-[calc(100vw-32px)]" />
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<React.Suspense fallback={<div className="h-[350px] w-[350px] max-w-[calc(100vw-32px)]" />}>
|
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
searchDisabled
|
searchDisabled
|
||||||
theme={(theme.mode === 'dark' ? 'dark' : 'light') as Theme}
|
theme={(theme.mode === 'dark' ? 'dark' : 'light') as Theme}
|
||||||
|
|
@ -485,12 +419,8 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</div>
|
||||||
size="sm"
|
<Button size="sm" type="submit" disabled={!canSubmit} variant="solid">
|
||||||
type="submit"
|
|
||||||
disabled={!content.trim() && mediaItems.length === 0 && !mediaType}
|
|
||||||
variant="solid"
|
|
||||||
>
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.CreatePost.Submit')}
|
{translate('::App.Platform.Intranet.SocialWall.CreatePost.Submit')}
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
@ -508,13 +438,6 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{/* Location Picker Modal */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{showLocationPicker && (
|
|
||||||
<LocationPicker onSelect={setLocation} onClose={() => setShowLocationPicker(false)} />
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
import React from 'react'
|
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
||||||
import { FaExternalLinkAlt, FaMapMarkerAlt } from 'react-icons/fa'
|
|
||||||
import Button from '@/components/ui/Button'
|
|
||||||
|
|
||||||
interface LocationData {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
address: string
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
placeId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationMapProps {
|
|
||||||
location: string // JSON string
|
|
||||||
className?: string
|
|
||||||
showDirections?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const LocationMap: React.FC<LocationMapProps> = ({
|
|
||||||
location,
|
|
||||||
className = '',
|
|
||||||
showDirections = true,
|
|
||||||
}) => {
|
|
||||||
const locationData: LocationData = JSON.parse(location)
|
|
||||||
|
|
||||||
const handleOpenGoogleMaps = () => {
|
|
||||||
const url = `https://www.google.com/maps/search/?api=1&query=${locationData.lat},${locationData.lng}&query_place_id=${locationData.placeId || ''}`
|
|
||||||
window.open(url, '_blank')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Google Maps Static API URL (gerçek uygulamada API key eklenecek)
|
|
||||||
const getMapImageUrl = () => {
|
|
||||||
const { lat, lng } = locationData
|
|
||||||
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}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const { translate } = useLocalization()
|
|
||||||
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 ${locationData.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">
|
|
||||||
<FaMapMarkerAlt 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">{locationData.name}</h3>
|
|
||||||
<p className="text-sm text-white/90 drop-shadow-md line-clamp-2">
|
|
||||||
{locationData.address}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Click to open overlay - invisible but clickable */}
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
onClick={handleOpenGoogleMaps}
|
|
||||||
variant="plain"
|
|
||||||
shape="none"
|
|
||||||
className="absolute inset-0 !h-full !w-full !rounded-none !p-0 cursor-pointer hover:!bg-transparent active:!bg-transparent focus:!bg-transparent"
|
|
||||||
aria-label={translate('::App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps')}
|
|
||||||
>
|
|
||||||
<span className="sr-only">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps')}
|
|
||||||
</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
|
|
||||||
type="button"
|
|
||||||
onClick={handleOpenGoogleMaps}
|
|
||||||
variant="solid"
|
|
||||||
icon={<FaExternalLinkAlt className="h-5 w-5" />}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationMap.ClickForDirections')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LocationMap
|
|
||||||
|
|
@ -1,430 +0,0 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react'
|
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
|
||||||
import { motion } from 'framer-motion'
|
|
||||||
import { FaTimes, FaSearch, FaMapMarkerAlt } from 'react-icons/fa'
|
|
||||||
import classNames from 'classnames'
|
|
||||||
import Button from '@/components/ui/Button'
|
|
||||||
|
|
||||||
interface LocationPickerProps {
|
|
||||||
onSelect: (location: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LocationData {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
address: string
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
placeId?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 { translate } = useLocalization()
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
|
||||||
const [locations, setLocations] = useState<LocationData[]>([])
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<LocationData | 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<ReturnType<typeof setTimeout>>()
|
|
||||||
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(translate('::App.Platform.Intranet.SocialWall.LocationPicker.ApiKeyError'))
|
|
||||||
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(translate('::App.Platform.Intranet.SocialWall.LocationPicker.GoogleMapsLoadError'))
|
|
||||||
}
|
|
||||||
|
|
||||||
document.head.appendChild(script)
|
|
||||||
scriptLoadedRef.current = true
|
|
||||||
}
|
|
||||||
|
|
||||||
loadGoogleMaps()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
searchInputRef.current?.focus()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getGooglePlacesErrorMessage = (status: any) => {
|
|
||||||
switch (status) {
|
|
||||||
case window.google.maps.places.PlacesServiceStatus.ZERO_RESULTS:
|
|
||||||
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.NoResults')
|
|
||||||
|
|
||||||
case window.google.maps.places.PlacesServiceStatus.OVER_QUERY_LIMIT:
|
|
||||||
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.OverQueryLimit')
|
|
||||||
|
|
||||||
case window.google.maps.places.PlacesServiceStatus.REQUEST_DENIED:
|
|
||||||
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.RequestDenied')
|
|
||||||
|
|
||||||
case window.google.maps.places.PlacesServiceStatus.INVALID_REQUEST:
|
|
||||||
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.InvalidRequest')
|
|
||||||
|
|
||||||
case window.google.maps.places.PlacesServiceStatus.UNKNOWN_ERROR:
|
|
||||||
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.UnknownError')
|
|
||||||
|
|
||||||
default:
|
|
||||||
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.SearchFailed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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([])
|
|
||||||
setError(getGooglePlacesErrorMessage(status))
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status !== window.google.maps.places.PlacesServiceStatus.OK) {
|
|
||||||
setLocations([])
|
|
||||||
setError(getGooglePlacesErrorMessage(status))
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!predictions || predictions.length === 0) {
|
|
||||||
setLocations([])
|
|
||||||
setError(translate('::App.Platform.Intranet.SocialWall.LocationPicker.NoResults'))
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setError(null)
|
|
||||||
|
|
||||||
const detailedLocations: LocationData[] = []
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completed === predictions.length) {
|
|
||||||
if (detailedLocations.length === 0) {
|
|
||||||
setError('Konum detayları alınamadı. API yetkilerini kontrol ediniz.')
|
|
||||||
} else {
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
setLocations(detailedLocations)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Location search error:', err)
|
|
||||||
setError(translate('::App.Platform.Intranet.SocialWall.LocationPicker.SearchError'))
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, 500) // 500ms debounce
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (debounceTimerRef.current) {
|
|
||||||
clearTimeout(debounceTimerRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchQuery, isGoogleLoaded])
|
|
||||||
|
|
||||||
const handleSelect = (location: LocationData) => {
|
|
||||||
setSelectedLocation(location)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirm = () => {
|
|
||||||
if (selectedLocation) {
|
|
||||||
onSelect(JSON.stringify(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">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.AddLocation')}
|
|
||||||
</h2>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={onClose}
|
|
||||||
variant="plain"
|
|
||||||
icon={<FaTimes className="h-5 w-5 text-gray-500 dark:text-gray-400" />}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div className="relative">
|
|
||||||
<FaSearch 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={translate(
|
|
||||||
'::App.Platform.Intranet.SocialWall.LocationPicker.SearchPlaceholder',
|
|
||||||
)}
|
|
||||||
disabled={!isGoogleLoaded}
|
|
||||||
className="w-full pl-10 pr-2 py-1 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">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.LoadingGoogleMaps')}
|
|
||||||
</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">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.LoadingGoogleMaps')}
|
|
||||||
</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">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.SearchingLocations')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<FaMapMarkerAlt 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">
|
|
||||||
<FaSearch className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.TypeToSearch')}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.Example')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : locations.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<FaMapMarkerAlt className="w-16 h-16 mx-auto mb-4 text-gray-400" />
|
|
||||||
<p className="text-gray-500 dark:text-gray-400">
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.NotFound')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{locations.map((location) => (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
key={location.id}
|
|
||||||
onClick={() => handleSelect(location)}
|
|
||||||
variant="plain"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="mt-1">
|
|
||||||
<FaMapMarkerAlt
|
|
||||||
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-800">
|
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{selectedLocation ? (
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<FaMapMarkerAlt className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
|
||||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
|
||||||
{selectedLocation.name}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span>
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.SelectLocation')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={onClose}
|
|
||||||
variant="plain"
|
|
||||||
>
|
|
||||||
{translate('::Cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleConfirm}
|
|
||||||
disabled={!selectedLocation}
|
|
||||||
variant="solid"
|
|
||||||
>
|
|
||||||
{translate('::ListForms.Wizard.Add')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default LocationPicker
|
|
||||||
|
|
@ -12,11 +12,32 @@ interface MediaManagerProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MediaKind = 'image' | 'video'
|
||||||
|
|
||||||
|
const readFileAsDataUrl = (file: File) =>
|
||||||
|
new Promise<string | null>((resolve) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as string)
|
||||||
|
reader.onerror = () => resolve(null)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
|
||||||
const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose }) => {
|
const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose }) => {
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
const [activeTab, setActiveTab] = useState<'upload' | 'url'>('upload')
|
const [activeTab, setActiveTab] = useState<'upload' | 'url'>('upload')
|
||||||
const [urlInput, setUrlInput] = useState('')
|
const [urlInput, setUrlInput] = useState('')
|
||||||
const [mediaType, setMediaType] = useState<'image' | 'video'>('image')
|
const [mediaType, setMediaType] = useState<MediaKind>('image')
|
||||||
|
|
||||||
|
// Backend tek bir medya kaydı tuttuğu için bir gönderide resim ve video
|
||||||
|
// karıştırılamaz; ilk eklenen öğe gönderinin tipini sabitler.
|
||||||
|
const lockedType = (media[0]?.type as MediaKind | undefined) ?? null
|
||||||
|
const effectiveType: MediaKind = lockedType ?? mediaType
|
||||||
|
const acceptedFiles = lockedType
|
||||||
|
? lockedType === 'video'
|
||||||
|
? 'video/*'
|
||||||
|
: 'image/*'
|
||||||
|
: 'image/*,video/*'
|
||||||
|
|
||||||
const tabClassName = (tab: 'upload' | 'url') =>
|
const tabClassName = (tab: 'upload' | 'url') =>
|
||||||
classNames(
|
classNames(
|
||||||
'!h-auto !items-center gap-2 !rounded-none border-b-2 !bg-transparent !px-4 !py-3 font-medium transition-colors hover:!bg-gray-50 active:!bg-transparent focus:!bg-transparent dark:hover:!bg-gray-700/50',
|
'!h-auto !items-center gap-2 !rounded-none border-b-2 !bg-transparent !px-4 !py-3 font-medium transition-colors hover:!bg-gray-50 active:!bg-transparent focus:!bg-transparent dark:hover:!bg-gray-700/50',
|
||||||
|
|
@ -25,43 +46,45 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100',
|
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100',
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const typeButtonClassName = (kind: MediaKind) =>
|
||||||
const files = e.target.files
|
classNames(
|
||||||
if (!files) return
|
'flex-1 !h-auto !rounded-lg !px-4 !py-2 font-medium transition-colors',
|
||||||
|
effectiveType === kind
|
||||||
|
? '!bg-blue-600 !text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 hover:!bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:!bg-gray-600',
|
||||||
|
)
|
||||||
|
|
||||||
const fileArray = Array.from(files)
|
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const readers = fileArray.map(
|
const input = e.target
|
||||||
(file) =>
|
const files = Array.from(input.files ?? [])
|
||||||
new Promise<SocialMediaDto>((resolve) => {
|
input.value = ''
|
||||||
const reader = new FileReader()
|
if (files.length === 0) return
|
||||||
reader.onload = () => {
|
|
||||||
resolve({
|
const results = await Promise.all(
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
files.map(async (file): Promise<SocialMediaDto | null> => {
|
||||||
type: file.type.startsWith('video/') ? 'video' : 'image',
|
const kind: MediaKind = file.type.startsWith('video/') ? 'video' : 'image'
|
||||||
urls: [reader.result as string],
|
if (lockedType && kind !== lockedType) return null
|
||||||
})
|
|
||||||
}
|
const dataUrl = await readFileAsDataUrl(file)
|
||||||
reader.readAsDataURL(file)
|
if (!dataUrl) return null
|
||||||
|
|
||||||
|
return { id: crypto.randomUUID(), type: kind, urls: [dataUrl] }
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
Promise.all(readers).then((newMedia) => {
|
const added = results.filter((item): item is SocialMediaDto => item !== null)
|
||||||
onChange([...media, ...newMedia])
|
if (added.length === 0) return
|
||||||
})
|
|
||||||
|
|
||||||
e.target.value = ''
|
// İlk seçim gönderinin tipini belirler; karışık seçimlerde ilk dosyanın tipi kazanır
|
||||||
|
const resolvedType = lockedType ?? (added[0].type as MediaKind)
|
||||||
|
onChange([...media, ...added.filter((item) => item.type === resolvedType)])
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUrlAdd = () => {
|
const handleUrlAdd = () => {
|
||||||
if (!urlInput.trim()) return
|
const url = urlInput.trim()
|
||||||
|
if (!url) return
|
||||||
|
|
||||||
const newMedia: SocialMediaDto = {
|
onChange([...media, { id: crypto.randomUUID(), type: effectiveType, urls: [url] }])
|
||||||
id: Math.random().toString(36).substr(2, 9),
|
|
||||||
type: mediaType,
|
|
||||||
urls: [urlInput],
|
|
||||||
}
|
|
||||||
|
|
||||||
onChange([...media, newMedia])
|
|
||||||
setUrlInput('')
|
setUrlInput('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +107,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.AddMedia')}
|
{translate('::App.Platform.Intranet.SocialWall.MediaManager.AddMedia')}
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
|
|
@ -95,6 +119,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-gray-200 dark:border-gray-700 px-2 py-2">
|
<div className="flex border-b border-gray-200 dark:border-gray-700 px-2 py-2">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={() => setActiveTab('upload')}
|
onClick={() => setActiveTab('upload')}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
|
|
@ -106,6 +131,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={() => setActiveTab('url')}
|
onClick={() => setActiveTab('url')}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
|
|
@ -119,7 +145,6 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-4 overflow-y-auto max-h-[calc(90vh-240px)]">
|
<div className="p-4 overflow-y-auto max-h-[calc(90vh-240px)]">
|
||||||
{activeTab === 'upload' ? (
|
{activeTab === 'upload' ? (
|
||||||
<div>
|
|
||||||
<label className="block">
|
<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">
|
<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">
|
||||||
<FaUpload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
<FaUpload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||||
|
|
@ -127,49 +152,41 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.ClickToSelectFile')}
|
{translate('::App.Platform.Intranet.SocialWall.MediaManager.ClickToSelectFile')}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{translate(
|
{translate('::App.Platform.Intranet.SocialWall.MediaManager.ImageOrVideoFormats')}
|
||||||
'::App.Platform.Intranet.SocialWall.MediaManager.ImageOrVideoFormats',
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*,video/*"
|
accept={acceptedFiles}
|
||||||
multiple
|
multiple
|
||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.MediaType')}
|
{translate('::App.Platform.Intranet.SocialWall.MediaManager.MediaType')}
|
||||||
</label>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={() => setMediaType('image')}
|
onClick={() => setMediaType('image')}
|
||||||
variant="solid"
|
disabled={lockedType === 'video'}
|
||||||
className={classNames(
|
variant="plain"
|
||||||
'flex-1 !h-auto !rounded-lg !px-4 !py-2 font-medium transition-colors',
|
shape="none"
|
||||||
mediaType === 'image'
|
className={typeButtonClassName('image')}
|
||||||
? '!bg-blue-600 !text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:!bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:!bg-gray-600',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.Image')}
|
{translate('::App.Platform.Intranet.SocialWall.MediaManager.Image')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={() => setMediaType('video')}
|
onClick={() => setMediaType('video')}
|
||||||
|
disabled={lockedType === 'image'}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
className={classNames(
|
className={typeButtonClassName('video')}
|
||||||
'flex-1 !h-auto !rounded-lg !px-4 !py-2 font-medium transition-colors',
|
|
||||||
mediaType === 'video'
|
|
||||||
? '!bg-blue-600 !text-white'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:!bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:!bg-gray-600',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.Video')}
|
{translate('::App.Platform.Intranet.SocialWall.MediaManager.Video')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -180,15 +197,21 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
type="url"
|
type="url"
|
||||||
value={urlInput}
|
value={urlInput}
|
||||||
onChange={(e) => setUrlInput(e.target.value)}
|
onChange={(e) => setUrlInput(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleUrlAdd()}
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleUrlAdd()
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
mediaType === 'image'
|
effectiveType === 'image'
|
||||||
? translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterImageUrl')
|
? translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterImageUrl')
|
||||||
: translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterVideoUrl')
|
: translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterVideoUrl')
|
||||||
}
|
}
|
||||||
className="flex-1 px-2 py-1 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"
|
className="flex-1 px-2 py-1 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
|
<Button
|
||||||
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleUrlAdd}
|
onClick={handleUrlAdd}
|
||||||
disabled={!urlInput.trim()}
|
disabled={!urlInput.trim()}
|
||||||
|
|
@ -217,19 +240,22 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
className="w-full h-24 object-cover rounded-lg"
|
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">
|
<div className="relative w-full h-24 bg-gray-900 rounded-lg">
|
||||||
<video
|
<video
|
||||||
src={item.urls?.[0]}
|
src={item.urls?.[0]}
|
||||||
className="w-full h-full object-cover rounded-lg"
|
className="w-full h-full object-cover rounded-lg"
|
||||||
|
muted
|
||||||
|
preload="metadata"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<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-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 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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={() => removeMedia(item.id)}
|
onClick={() => removeMedia(item.id)}
|
||||||
variant="solid"
|
variant="solid"
|
||||||
shape="circle"
|
shape="circle"
|
||||||
|
|
@ -250,14 +276,11 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<Button
|
<Button type="button" size="sm" onClick={onClose} variant="plain">
|
||||||
size="sm"
|
|
||||||
onClick={onClose}
|
|
||||||
variant="plain"
|
|
||||||
>
|
|
||||||
{translate('::Cancel')}
|
{translate('::Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={media.length === 0}
|
disabled={media.length === 0}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react'
|
import React, { useState, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import 'dayjs/locale/tr'
|
import DOMPurify from 'dompurify'
|
||||||
import { FaHeart, FaRegHeart, FaRegCommentAlt, FaTrash, FaPaperPlane } from 'react-icons/fa'
|
import {
|
||||||
|
FaHeart,
|
||||||
|
FaRegHeart,
|
||||||
|
FaRegCommentAlt,
|
||||||
|
FaTrash,
|
||||||
|
FaPaperPlane,
|
||||||
|
FaExpand,
|
||||||
|
} from 'react-icons/fa'
|
||||||
import MediaLightbox from './MediaLightbox'
|
import MediaLightbox from './MediaLightbox'
|
||||||
import UserProfileCard from './UserProfileCard'
|
import UserProfileCard from './UserProfileCard'
|
||||||
import { SocialPostDto } from '@/proxy/intranet/models'
|
import { SocialPostDto } from '@/proxy/intranet/models'
|
||||||
|
|
@ -13,8 +20,13 @@ import { useStoreState } from '@/store/store'
|
||||||
import { AVATAR_URL } from '@/constants/app.constant'
|
import { AVATAR_URL } from '@/constants/app.constant'
|
||||||
import { Avatar, Button } from '@/components/ui'
|
import { Avatar, Button } from '@/components/ui'
|
||||||
|
|
||||||
|
// Aktif dil `useLocale` tarafından global olarak ayarlanır; burada sabitlenmez.
|
||||||
dayjs.extend(relativeTime)
|
dayjs.extend(relativeTime)
|
||||||
dayjs.locale('tr')
|
|
||||||
|
const MAX_VISIBLE_IMAGES = 4
|
||||||
|
|
||||||
|
const ACTION_BUTTON_CLASS =
|
||||||
|
'!h-auto !items-center gap-2 !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-gray-600 transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent dark:text-gray-400'
|
||||||
|
|
||||||
interface PostItemProps {
|
interface PostItemProps {
|
||||||
post: SocialPostDto
|
post: SocialPostDto
|
||||||
|
|
@ -24,6 +36,12 @@ interface PostItemProps {
|
||||||
onVote: (postId: string, optionId: string) => void
|
onVote: (postId: string, optionId: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const imageGridClass = (count: number) => {
|
||||||
|
if (count === 1) return { grid: '', cell: 'aspect-video' }
|
||||||
|
if (count === 3) return { grid: 'grid gap-1 grid-cols-3', cell: 'aspect-square' }
|
||||||
|
return { grid: 'grid gap-1 grid-cols-2', cell: 'aspect-square' }
|
||||||
|
}
|
||||||
|
|
||||||
const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete, onVote }) => {
|
const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete, onVote }) => {
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
const [showComments, setShowComments] = useState(false)
|
const [showComments, setShowComments] = useState(false)
|
||||||
|
|
@ -35,6 +53,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
||||||
const [hoveredCommentAuthor, setHoveredCommentAuthor] = useState<string | null>(null)
|
const [hoveredCommentAuthor, setHoveredCommentAuthor] = useState<string | null>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
const { user } = useStoreState((state) => state.auth)
|
const { user } = useStoreState((state) => state.auth)
|
||||||
|
|
||||||
const postUser = post.user ?? user ?? {}
|
const postUser = post.user ?? user ?? {}
|
||||||
const postComments = post.comments ?? []
|
const postComments = post.comments ?? []
|
||||||
const postLikeCount = post.likeCount ?? 0
|
const postLikeCount = post.likeCount ?? 0
|
||||||
|
|
@ -44,91 +63,68 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
||||||
postUser.fullName || [postUser.name, postUser.surname].filter(Boolean).join(' ') || '-'
|
postUser.fullName || [postUser.name, postUser.surname].filter(Boolean).join(' ') || '-'
|
||||||
const postUserTitle = postUser.jobPositions?.[0]?.name || ''
|
const postUserTitle = postUser.jobPositions?.[0]?.name || ''
|
||||||
|
|
||||||
// Intersection Observer for video autoplay/pause
|
const mediaType = post.media?.type
|
||||||
|
const sanitizedContent = useMemo(
|
||||||
|
() => DOMPurify.sanitize(post.content || ''),
|
||||||
|
[post.content],
|
||||||
|
)
|
||||||
|
|
||||||
|
// Video ekranda görünürken oynat, çıkınca durdur
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current
|
const video = videoRef.current
|
||||||
if (!video) return
|
if (!video) return
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
([entry]) => {
|
||||||
entries.forEach((entry) => {
|
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
// Video ekranda görünür - oynat
|
video.play().catch(() => {
|
||||||
video.play().catch((err) => {
|
// Tarayıcı otomatik oynatmayı engelledi — sessizce yoksay
|
||||||
console.log('Video autoplay failed:', err)
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// Video ekrandan çıktı - durdur
|
|
||||||
video.pause()
|
video.pause()
|
||||||
}
|
}
|
||||||
})
|
|
||||||
},
|
|
||||||
{
|
|
||||||
threshold: 0.5, // Video %50 görünür olduğunda oynat
|
|
||||||
},
|
},
|
||||||
|
{ threshold: 0.5 },
|
||||||
)
|
)
|
||||||
|
|
||||||
observer.observe(video)
|
observer.observe(video)
|
||||||
|
return () => observer.disconnect()
|
||||||
return () => {
|
}, [mediaType])
|
||||||
observer.disconnect()
|
|
||||||
}
|
|
||||||
}, [post.media?.type])
|
|
||||||
|
|
||||||
const handleSubmitComment = (e: React.FormEvent) => {
|
const handleSubmitComment = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (commentText.trim()) {
|
const trimmed = commentText.trim()
|
||||||
onComment(post.id, commentText)
|
if (!trimmed) return
|
||||||
|
onComment(post.id, trimmed)
|
||||||
setCommentText('')
|
setCommentText('')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const getImageLayout = (images: string[]) => {
|
const renderImages = (urls: string[]) => {
|
||||||
const count = images.length
|
const { grid, cell } = imageGridClass(urls.length)
|
||||||
if (count === 1) return 'single'
|
const displayImages = showAllImages ? urls : urls.slice(0, MAX_VISIBLE_IMAGES)
|
||||||
if (count === 2) return 'double'
|
const hiddenCount = urls.length - MAX_VISIBLE_IMAGES
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className={classNames('mt-3 rounded-lg overflow-hidden', grid)}>
|
||||||
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) => (
|
{displayImages.map((url, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={`${index}-${url}`}
|
||||||
className={classNames('relative', {
|
className={classNames('relative', cell, {
|
||||||
'col-span-2': layout === 'triple' && index === 0,
|
'col-span-2': urls.length === 3 && index === 0,
|
||||||
'aspect-video': layout === 'single',
|
|
||||||
'aspect-square': layout !== 'single',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={`Post image ${index + 1}`}
|
alt={`${postUserFullName} ${index + 1}`}
|
||||||
|
loading="lazy"
|
||||||
className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
|
className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLightboxIndex(index)
|
setLightboxIndex(index)
|
||||||
setLightboxOpen(true)
|
setLightboxOpen(true)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{hasMore && index === 3 && !showAllImages && post.media?.urls && (
|
{hiddenCount > 0 && index === MAX_VISIBLE_IMAGES - 1 && !showAllImages && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black bg-opacity-60 flex items-center justify-center cursor-pointer"
|
className="absolute inset-0 bg-black bg-opacity-60 flex items-center justify-center cursor-pointer"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -136,78 +132,80 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
||||||
setShowAllImages(true)
|
setShowAllImages(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-white text-2xl font-bold">
|
<span className="text-white text-2xl font-bold">+{hiddenCount}</span>
|
||||||
+{post.media.urls.length - 4}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{lightboxOpen && (
|
||||||
<MediaLightbox
|
<MediaLightbox
|
||||||
isOpen={lightboxOpen}
|
isOpen
|
||||||
onClose={() => setLightboxOpen(false)}
|
onClose={() => setLightboxOpen(false)}
|
||||||
media={{ type: 'image', urls: post.media.urls }}
|
media={{ type: 'image', urls }}
|
||||||
startIndex={lightboxIndex}
|
startIndex={lightboxIndex}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
break
|
|
||||||
|
|
||||||
case 'video':
|
const renderVideo = (url: string) => (
|
||||||
if (post.media.urls && post.media.urls.length > 0) {
|
|
||||||
return (
|
|
||||||
<>
|
<>
|
||||||
<div
|
<div className="mt-3 rounded-lg overflow-hidden relative group">
|
||||||
className="mt-3 rounded-lg overflow-hidden cursor-pointer relative group"
|
|
||||||
onClick={() => setLightboxOpen(true)}
|
|
||||||
>
|
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={post.media.urls[0]}
|
src={url}
|
||||||
className="w-full max-h-96 object-cover"
|
className="w-full max-h-96 object-cover"
|
||||||
controls
|
controls
|
||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
loop
|
loop
|
||||||
/>
|
/>
|
||||||
</div>
|
{/* `controls` ile çakışmaması için tam ekran ayrı bir düğmede */}
|
||||||
<MediaLightbox
|
<Button
|
||||||
isOpen={lightboxOpen}
|
type="button"
|
||||||
onClose={() => setLightboxOpen(false)}
|
onClick={() => setLightboxOpen(true)}
|
||||||
media={{ type: 'video', urls: [post.media.urls[0]] }}
|
variant="plain"
|
||||||
|
shape="none"
|
||||||
|
icon={<FaExpand className="h-4 w-4" />}
|
||||||
|
className="absolute top-2 right-2 !h-7 !w-7 !rounded-lg !px-0 bg-black/50 text-white opacity-0 transition-opacity hover:!bg-black/70 group-hover:opacity-100"
|
||||||
|
title={translate('::App.Platform.Intranet.SocialWall.PostItem.Fullscreen')}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
{lightboxOpen && (
|
||||||
|
<MediaLightbox
|
||||||
|
isOpen
|
||||||
|
onClose={() => setLightboxOpen(false)}
|
||||||
|
media={{ type: 'video', urls: [url] }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
break
|
|
||||||
|
|
||||||
case 'poll':
|
const renderPoll = () => {
|
||||||
if (post.media.pollQuestion && post.media.pollOptions) {
|
const media = post.media
|
||||||
const pollEndsAt = post.media.pollEndsAt ? new Date(post.media.pollEndsAt) : null
|
if (!media?.pollQuestion || !media.pollOptions) return null
|
||||||
|
|
||||||
|
const pollEndsAt = media.pollEndsAt ? new Date(media.pollEndsAt) : null
|
||||||
const isExpired = pollEndsAt ? new Date() > pollEndsAt : false
|
const isExpired = pollEndsAt ? new Date() > pollEndsAt : false
|
||||||
const hasVoted = !!post.media.pollUserVoteId
|
const hasVoted = !!media.pollUserVoteId
|
||||||
const totalVotes = post.media.pollTotalVotes || 0
|
const totalVotes = media.pollTotalVotes || 0
|
||||||
const pollUserVoteId = post.media.pollUserVoteId
|
const isLocked = hasVoted || isExpired
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
<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">
|
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-3">{media.pollQuestion}</h4>
|
||||||
{post.media.pollQuestion}
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{post.media.pollOptions.map((option) => {
|
{media.pollOptions.map((option) => {
|
||||||
const percentage = totalVotes > 0 ? ((option.votes ?? 0) / totalVotes) * 100 : 0
|
const percentage = totalVotes > 0 ? ((option.votes ?? 0) / totalVotes) * 100 : 0
|
||||||
const isSelected = pollUserVoteId === option.id
|
const isSelected = media.pollUserVoteId === option.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
key={option.id}
|
key={option.id}
|
||||||
onClick={() =>
|
onClick={() => option.id && !isLocked && onVote(post.id, option.id)}
|
||||||
option.id && !hasVoted && !isExpired && onVote(post.id, option.id)
|
disabled={isLocked}
|
||||||
}
|
|
||||||
disabled={hasVoted || isExpired}
|
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
className={classNames(
|
className={classNames(
|
||||||
|
|
@ -215,8 +213,8 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
||||||
{
|
{
|
||||||
'!bg-blue-100 dark:!bg-blue-900 border-2 border-blue-500': isSelected,
|
'!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':
|
'bg-white dark:bg-gray-600 hover:!bg-gray-50 dark:hover:!bg-gray-500':
|
||||||
!isSelected && !hasVoted && !isExpired,
|
!isSelected && !isLocked,
|
||||||
'bg-white dark:bg-gray-600 cursor-not-allowed': hasVoted || isExpired,
|
'bg-white dark:bg-gray-600 cursor-not-allowed': isLocked,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -241,21 +239,42 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
{totalVotes} oy •{' '}
|
{translate('::App.Platform.Intranet.SocialWall.PostItem.VoteCount', {
|
||||||
{isExpired
|
count: totalVotes,
|
||||||
? 'Sona erdi'
|
})}
|
||||||
: pollEndsAt
|
{isExpired && (
|
||||||
? dayjs(pollEndsAt).fromNow() + ' bitiyor'
|
<> • {translate('::App.Platform.Intranet.SocialWall.PostItem.PollEnded')}</>
|
||||||
: ''}
|
)}
|
||||||
|
{!isExpired && pollEndsAt && (
|
||||||
|
<>
|
||||||
|
{' • '}
|
||||||
|
{translate('::App.Platform.Intranet.SocialWall.PostItem.PollEndsIn', {
|
||||||
|
time: dayjs(pollEndsAt).fromNow(true),
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const renderMedia = () => {
|
||||||
|
const media = post.media
|
||||||
|
if (!media) return null
|
||||||
|
|
||||||
|
const urls = (media.urls ?? []).filter(Boolean)
|
||||||
|
|
||||||
|
switch (media.type) {
|
||||||
|
case 'image':
|
||||||
|
return urls.length > 0 ? renderImages(urls) : null
|
||||||
|
case 'video':
|
||||||
|
return urls.length > 0 ? renderVideo(urls[0]) : null
|
||||||
|
case 'poll':
|
||||||
|
return renderPoll()
|
||||||
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|
@ -313,7 +332,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div
|
<div
|
||||||
className="whitespace-pre-wrap text-gray-800 dark:text-gray-200"
|
className="whitespace-pre-wrap text-gray-800 dark:text-gray-200"
|
||||||
dangerouslySetInnerHTML={{ __html: post.content || '' }}
|
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||||
/>
|
/>
|
||||||
{renderMedia()}
|
{renderMedia()}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -331,19 +350,18 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
||||||
<FaRegHeart className="h-5 w-5" />
|
<FaRegHeart className="h-5 w-5" />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className={classNames(
|
className={ACTION_BUTTON_CLASS}
|
||||||
'!h-auto !items-center gap-2 !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-gray-600 transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent dark:text-gray-400',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{postLikeCount}</span>
|
<span className="text-sm font-medium">{postLikeCount}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowComments(!showComments)}
|
onClick={() => setShowComments((prev) => !prev)}
|
||||||
variant="plain"
|
variant="plain"
|
||||||
shape="none"
|
shape="none"
|
||||||
icon={<FaRegCommentAlt className="h-5 w-5" />}
|
icon={<FaRegCommentAlt className="h-5 w-5" />}
|
||||||
className="!h-auto !items-center gap-2 !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-gray-600 transition-colors hover:!bg-transparent hover:text-blue-600 active:!bg-transparent focus:!bg-transparent dark:text-gray-400"
|
className={classNames(ACTION_BUTTON_CLASS, 'hover:text-blue-600')}
|
||||||
|
aria-expanded={showComments}
|
||||||
>
|
>
|
||||||
<span className="text-sm font-medium">{postComments.length}</span>
|
<span className="text-sm font-medium">{postComments.length}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -398,11 +416,11 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
||||||
{hoveredCommentAuthor === comment.id && (
|
{hoveredCommentAuthor === comment.id && (
|
||||||
<UserProfileCard
|
<UserProfileCard
|
||||||
user={{
|
user={{
|
||||||
id: comment.user?.id || '',
|
id: comment.user?.id ?? '',
|
||||||
name: comment.user?.fullName || '',
|
name: comment.user?.fullName ?? '',
|
||||||
title: comment.user?.jobPositions?.[0]?.name || '',
|
title: comment.user?.jobPositions?.[0]?.name ?? '',
|
||||||
phoneNumber: comment.user.phoneNumber,
|
phoneNumber: comment.user?.phoneNumber,
|
||||||
tenantId: comment.user?.tenantId || '',
|
tenantId: comment.user?.tenantId ?? '',
|
||||||
}}
|
}}
|
||||||
position="bottom"
|
position="bottom"
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,90 @@
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||||
import { AnimatePresence } from 'framer-motion'
|
import { AnimatePresence } from 'framer-motion'
|
||||||
|
import classNames from 'classnames'
|
||||||
import PostItem from './PostItem'
|
import PostItem from './PostItem'
|
||||||
import CreatePost from './CreatePost'
|
import CreatePost from './CreatePost'
|
||||||
import { SocialMediaDto, SocialPostDto } from '@/proxy/intranet/models'
|
import { SocialMediaDto, SocialPostDto } from '@/proxy/intranet/models'
|
||||||
import { intranetService } from '@/services/intranet.service'
|
import { intranetService } from '@/services/intranet.service'
|
||||||
|
import type { CreateSocialPostInput } from '@/services/intranet.service'
|
||||||
import Button from '@/components/ui/Button'
|
import Button from '@/components/ui/Button'
|
||||||
import type { DashboardWidgetDefinition } from '../dashboardWidget'
|
import type { DashboardWidgetDefinition } from '../dashboardWidget'
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
|
|
||||||
|
type PostFilter = 'all' | 'mine'
|
||||||
|
|
||||||
|
const TAB_CLASS =
|
||||||
|
'!h-auto !rounded-none border-b-2 !bg-transparent !px-1 !pb-3 !pt-0 font-medium transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent'
|
||||||
|
|
||||||
|
const buildMediaInput = (
|
||||||
|
media?: {
|
||||||
|
type: 'mixed' | 'poll'
|
||||||
|
mediaItems?: SocialMediaDto[]
|
||||||
|
poll?: { question: string; options: Array<{ text: string }> }
|
||||||
|
},
|
||||||
|
): CreateSocialPostInput['media'] => {
|
||||||
|
if (!media) return undefined
|
||||||
|
|
||||||
|
if (media.type === 'poll' && media.poll) {
|
||||||
|
return {
|
||||||
|
type: 'poll',
|
||||||
|
pollQuestion: media.poll.question,
|
||||||
|
pollOptions: media.poll.options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = media.mediaItems ?? []
|
||||||
|
if (items.length === 0) return undefined
|
||||||
|
|
||||||
|
// Backend tek bir medya kaydı tutar: tüm öğeler aynı tipte olmak zorunda
|
||||||
|
// (MediaManager bunu zorunlu kılar), tip ilk öğeden türetilir.
|
||||||
|
const type = items[0].type === 'video' ? 'video' : 'image'
|
||||||
|
const urls = items.flatMap((item) => item.urls ?? []).filter(Boolean)
|
||||||
|
|
||||||
|
return urls.length > 0 ? { type, urls } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
const SocialWall: React.FC = () => {
|
const SocialWall: React.FC = () => {
|
||||||
|
const { translate } = useLocalization()
|
||||||
const [posts, setPosts] = useState<SocialPostDto[]>([])
|
const [posts, setPosts] = useState<SocialPostDto[]>([])
|
||||||
const [skipCount, setSkipCount] = useState(0)
|
const [filter, setFilter] = useState<PostFilter>('all')
|
||||||
const [hasMore, setHasMore] = useState(true)
|
const [hasMore, setHasMore] = useState(true)
|
||||||
const [loadingMore, setLoadingMore] = useState(false)
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||||
|
const skipCountRef = useRef(0)
|
||||||
|
const loadingRef = useRef(false)
|
||||||
|
const hasMoreRef = useRef(true)
|
||||||
|
|
||||||
const loadMore = useCallback(async () => {
|
const loadMore = useCallback(async () => {
|
||||||
if (loadingMore || !hasMore) return
|
if (loadingRef.current || !hasMoreRef.current) return
|
||||||
|
loadingRef.current = true
|
||||||
setLoadingMore(true)
|
setLoadingMore(true)
|
||||||
try {
|
try {
|
||||||
const res = await intranetService.getIntranetSocialPosts(skipCount, PAGE_SIZE)
|
const res = await intranetService.getIntranetSocialPosts(skipCountRef.current, PAGE_SIZE)
|
||||||
const newPosts = res.data ?? []
|
const fetched = res.data ?? []
|
||||||
|
skipCountRef.current += fetched.length
|
||||||
|
hasMoreRef.current = fetched.length === PAGE_SIZE
|
||||||
|
setHasMore(hasMoreRef.current)
|
||||||
setPosts((prev) => {
|
setPosts((prev) => {
|
||||||
const existingIds = new Set(prev.map((p) => p.id))
|
const existingIds = new Set(prev.map((p) => p.id))
|
||||||
const unique = newPosts.filter((p) => !existingIds.has(p.id))
|
return [...prev, ...fetched.filter((p) => !existingIds.has(p.id))]
|
||||||
return [...prev, ...unique]
|
|
||||||
})
|
})
|
||||||
setSkipCount((s) => s + newPosts.length)
|
|
||||||
setHasMore(newPosts.length === PAGE_SIZE)
|
|
||||||
} catch {
|
} catch {
|
||||||
// error handled by apiService
|
// hata apiService tarafından ele alınıyor
|
||||||
} finally {
|
} finally {
|
||||||
|
loadingRef.current = false
|
||||||
setLoadingMore(false)
|
setLoadingMore(false)
|
||||||
}
|
}
|
||||||
}, [loadingMore, hasMore, skipCount])
|
}, [])
|
||||||
|
|
||||||
// Sentinel observer — ilk yükleme de dahil tüm sayfaları bu tetikler
|
// Sentinel observer — ilk yükleme dahil tüm sayfaları bu tetikler.
|
||||||
|
// posts.length bağımlılığı, sentinel hâlâ görünür durumdaysa bir sonraki
|
||||||
|
// sayfanın da yüklenmesini sağlar.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sentinel = sentinelRef.current
|
const sentinel = sentinelRef.current
|
||||||
if (!sentinel) return
|
if (!sentinel || !hasMore) return
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (entries[0].isIntersecting) loadMore()
|
if (entries[0].isIntersecting) loadMore()
|
||||||
|
|
@ -49,113 +93,64 @@ const SocialWall: React.FC = () => {
|
||||||
)
|
)
|
||||||
observer.observe(sentinel)
|
observer.observe(sentinel)
|
||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [loadMore])
|
}, [loadMore, hasMore, posts.length])
|
||||||
const [filter, setFilter] = useState<'all' | 'mine'>('all')
|
|
||||||
const { translate } = useLocalization()
|
|
||||||
|
|
||||||
const updatePosts = (updated: SocialPostDto[]) => {
|
const handleCreatePost: React.ComponentProps<typeof CreatePost>['onCreatePost'] = async ({
|
||||||
setPosts(updated)
|
content,
|
||||||
}
|
media,
|
||||||
|
|
||||||
const handleCreatePost = async (postData: {
|
|
||||||
content: string
|
|
||||||
location?: string
|
|
||||||
media?: {
|
|
||||||
type: 'mixed' | 'poll'
|
|
||||||
mediaItems?: SocialMediaDto[]
|
|
||||||
poll?: {
|
|
||||||
question: string
|
|
||||||
options: Array<{ text: string }>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}) => {
|
}) => {
|
||||||
let mediaInput:
|
|
||||||
| {
|
|
||||||
type: 'image' | 'video' | 'poll'
|
|
||||||
urls?: string[]
|
|
||||||
pollQuestion?: string
|
|
||||||
pollOptions?: { text: string }[]
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
if (postData.media) {
|
|
||||||
if (postData.media.type === 'mixed' && postData.media.mediaItems) {
|
|
||||||
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) {
|
|
||||||
mediaInput = {
|
|
||||||
type: 'image',
|
|
||||||
urls: images.map((i) => i.urls?.[0]).filter(Boolean) as string[],
|
|
||||||
}
|
|
||||||
} else if (videos.length > 0 && images.length === 0) {
|
|
||||||
mediaInput = { type: 'video', urls: videos[0].urls || [] }
|
|
||||||
} else if (images.length > 0 || videos.length > 0) {
|
|
||||||
mediaInput = {
|
|
||||||
type: 'image',
|
|
||||||
urls: images.map((i) => i.urls?.[0]).filter(Boolean) as string[],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (postData.media.type === 'poll' && postData.media.poll) {
|
|
||||||
mediaInput = {
|
|
||||||
type: 'poll',
|
|
||||||
pollQuestion: postData.media.poll.question,
|
|
||||||
pollOptions: postData.media.poll.options,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await intranetService.createSocialPost({
|
const response = await intranetService.createSocialPost({
|
||||||
content: postData.content,
|
content,
|
||||||
locationJson: postData.location,
|
media: buildMediaInput(media),
|
||||||
media: mediaInput,
|
|
||||||
})
|
})
|
||||||
updatePosts([response.data, ...posts])
|
// Yeni gönderi listenin başına eklendiği için sayfalama ofseti de kaymalı
|
||||||
|
skipCountRef.current += 1
|
||||||
|
setPosts((prev) => [response.data, ...prev])
|
||||||
} catch {
|
} catch {
|
||||||
// error handled by apiService
|
// hata apiService tarafından ele alınıyor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLike = async (postId: string) => {
|
const handleLike = async (postId: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await intranetService.likeSocialPost(postId)
|
const response = await intranetService.likeSocialPost(postId)
|
||||||
updatePosts(posts.map((p) => (p.id === postId ? response.data : p)))
|
setPosts((prev) => prev.map((p) => (p.id === postId ? response.data : p)))
|
||||||
} catch {
|
} catch {
|
||||||
// error handled by apiService
|
// hata apiService tarafından ele alınıyor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleComment = async (postId: string, content: string) => {
|
const handleComment = async (postId: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await intranetService.commentSocialPost(postId, content)
|
const response = await intranetService.commentSocialPost(postId, content)
|
||||||
updatePosts(
|
setPosts((prev) =>
|
||||||
posts.map((p) =>
|
prev.map((p) =>
|
||||||
p.id === postId ? { ...p, comments: [...(p.comments || []), response.data] } : p,
|
p.id === postId ? { ...p, comments: [...(p.comments || []), response.data] } : p,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// error handled by apiService
|
// hata apiService tarafından ele alınıyor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (postId: string) => {
|
const handleDelete = async (postId: string) => {
|
||||||
if (window.confirm(translate('::App.Platform.Intranet.SocialWall.DeleteConfirm'))) {
|
if (!window.confirm(translate('::App.Platform.Intranet.SocialWall.DeleteConfirm'))) return
|
||||||
try {
|
try {
|
||||||
await intranetService.deleteSocialPost(postId)
|
await intranetService.deleteSocialPost(postId)
|
||||||
updatePosts(posts.filter((p) => p.id !== postId))
|
skipCountRef.current = Math.max(0, skipCountRef.current - 1)
|
||||||
|
setPosts((prev) => prev.filter((p) => p.id !== postId))
|
||||||
} catch {
|
} catch {
|
||||||
// error handled by apiService
|
// hata apiService tarafından ele alınıyor
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVote = async (postId: string, optionId: string) => {
|
const handleVote = async (postId: string, optionId: string) => {
|
||||||
try {
|
try {
|
||||||
await intranetService.voteSocialPoll(postId, optionId)
|
await intranetService.voteSocialPoll(postId, optionId)
|
||||||
updatePosts(
|
setPosts((prev) =>
|
||||||
posts.map((p) => {
|
prev.map((p) => {
|
||||||
if (p.id === postId && p.media?.type === 'poll' && p.media.pollOptions) {
|
if (p.id !== postId || p.media?.type !== 'poll' || !p.media.pollOptions) return p
|
||||||
if (p.media.pollUserVoteId) return p
|
if (p.media.pollUserVoteId) return p
|
||||||
return {
|
return {
|
||||||
...p,
|
...p,
|
||||||
|
|
@ -168,45 +163,38 @@ const SocialWall: React.FC = () => {
|
||||||
pollUserVoteId: optionId,
|
pollUserVoteId: optionId,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return p
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
// error handled by apiService
|
// hata apiService tarafından ele alınıyor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredPosts = filter === 'mine' ? posts.filter((post) => post.isOwnPost) : posts
|
const filteredPosts = filter === 'mine' ? posts.filter((post) => post.isOwnPost) : posts
|
||||||
|
|
||||||
|
const renderTab = (value: PostFilter, labelKey: string) => (
|
||||||
|
<Button
|
||||||
|
onClick={() => setFilter(value)}
|
||||||
|
variant="plain"
|
||||||
|
shape="none"
|
||||||
|
aria-pressed={filter === value}
|
||||||
|
className={classNames(
|
||||||
|
TAB_CLASS,
|
||||||
|
filter === value
|
||||||
|
? '!border-b-blue-600 !text-blue-600'
|
||||||
|
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{translate(labelKey)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto px-4">
|
<div className="mx-auto px-4">
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs */}
|
||||||
<div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
<div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<Button
|
{renderTab('all', '::App.Platform.Intranet.SocialWall.AllPosts')}
|
||||||
onClick={() => setFilter('all')}
|
{renderTab('mine', '::App.Platform.Intranet.SocialWall.MyPosts')}
|
||||||
variant="plain"
|
|
||||||
shape="none"
|
|
||||||
className={`!h-auto !rounded-none border-b-2 !bg-transparent !px-1 !pb-3 !pt-0 font-medium transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent ${
|
|
||||||
filter === 'all'
|
|
||||||
? '!border-b-blue-600 !text-blue-600'
|
|
||||||
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.AllPosts')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setFilter('mine')}
|
|
||||||
variant="plain"
|
|
||||||
shape="none"
|
|
||||||
className={`!h-auto !rounded-none border-b-2 !bg-transparent !px-1 !pb-3 !pt-0 font-medium transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent ${
|
|
||||||
filter === 'mine'
|
|
||||||
? '!border-b-blue-600 !text-blue-600'
|
|
||||||
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{translate('::App.Platform.Intranet.SocialWall.MyPosts')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Post */}
|
{/* Create Post */}
|
||||||
|
|
@ -214,8 +202,7 @@ const SocialWall: React.FC = () => {
|
||||||
|
|
||||||
{/* Posts Feed */}
|
{/* Posts Feed */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{filteredPosts.length > 0 ? (
|
{filteredPosts.map((post) => (
|
||||||
filteredPosts.map((post) => (
|
|
||||||
<PostItem
|
<PostItem
|
||||||
key={post.id}
|
key={post.id}
|
||||||
post={post}
|
post={post}
|
||||||
|
|
@ -224,8 +211,10 @@ const SocialWall: React.FC = () => {
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onVote={handleVote}
|
onVote={handleVote}
|
||||||
/>
|
/>
|
||||||
))
|
))}
|
||||||
) : (
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{filteredPosts.length === 0 && !loadingMore && (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
||||||
{filter === 'mine'
|
{filter === 'mine'
|
||||||
|
|
@ -234,11 +223,8 @@ const SocialWall: React.FC = () => {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Infinite scroll sentinel */}
|
{/* Infinite scroll sentinel */}
|
||||||
{filter === 'all' && (
|
|
||||||
<>
|
|
||||||
<div ref={sentinelRef} className="h-1" />
|
<div ref={sentinelRef} className="h-1" />
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
<div className="flex justify-center py-6">
|
<div className="flex justify-center py-6">
|
||||||
|
|
@ -247,12 +233,9 @@ const SocialWall: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
{!hasMore && posts.length > 0 && (
|
{!hasMore && posts.length > 0 && (
|
||||||
<p className="text-center text-sm text-gray-400 dark:text-gray-600 py-6">
|
<p className="text-center text-sm text-gray-400 dark:text-gray-600 py-6">
|
||||||
{translate('::App.Platform.Intranet.SocialWall.AllPostsLoaded') ||
|
{translate('::App.Platform.Intranet.SocialWall.AllPostsLoaded')}
|
||||||
'Tüm gönderiler yüklendi'}
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,10 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
||||||
const currentLocale = useLocale()
|
const currentLocale = useLocale()
|
||||||
const { translate } = useLocalization()
|
const { translate } = useLocalization()
|
||||||
const [hoveredSurveyId, setHoveredSurveyId] = useState<string | null>(null)
|
const [hoveredSurveyId, setHoveredSurveyId] = useState<string | null>(null)
|
||||||
|
const surveyList = surveys ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 overflow-hidden">
|
<div className="bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 overflow-hidden">
|
||||||
{/* Header with gradient */}
|
|
||||||
|
|
||||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
<h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
<FaClipboardCheck className="w-5 h-5" />
|
<FaClipboardCheck className="w-5 h-5" />
|
||||||
|
|
@ -30,7 +29,7 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 space-y-4 bg-white dark:bg-gray-800">
|
<div className="p-3 space-y-4 bg-white dark:bg-gray-800">
|
||||||
{surveys?.map((survey) => {
|
{surveyList.map((survey) => {
|
||||||
const daysLeft = dayjs(survey.deadline).diff(dayjs(), 'day')
|
const daysLeft = dayjs(survey.deadline).diff(dayjs(), 'day')
|
||||||
const urgency = daysLeft <= 3 ? 'urgent' : daysLeft <= 7 ? 'warning' : 'normal'
|
const urgency = daysLeft <= 3 ? 'urgent' : daysLeft <= 7 ? 'warning' : 'normal'
|
||||||
const isCompleted = !!survey.myResponse
|
const isCompleted = !!survey.myResponse
|
||||||
|
|
@ -114,7 +113,7 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Survey Stats */}
|
{/* Survey Stats */}
|
||||||
<div className="grid grid-cols-3 gap-4 mb-4">
|
<div className="grid grid-cols-2 gap-4 mb-4">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<div className="p-1.5 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
<div className="p-1.5 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
|
||||||
<FaQuestionCircle className="w-3 h-3 text-blue-600 dark:text-blue-300" />
|
<FaQuestionCircle className="w-3 h-3 text-blue-600 dark:text-blue-300" />
|
||||||
|
|
@ -143,35 +142,6 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<div className="p-1.5 bg-purple-100 dark:bg-purple-900/40 rounded-lg">
|
|
||||||
<FaClock className="w-3 h-3 text-purple-600 dark:text-purple-300" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{translate('::App.Platform.Intranet.Widgets.ActiveSurveys.Duration')}
|
|
||||||
</p>
|
|
||||||
<p className="font-semibold text-gray-900 dark:text-white">~5dk</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex justify-between text-xs mb-1">
|
|
||||||
<span className="text-gray-600 dark:text-gray-400">
|
|
||||||
{translate('::App.Platform.Intranet.Widgets.ActiveSurveys.CompletionRate')}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-800 dark:text-gray-100 font-medium">
|
|
||||||
{Math.round((survey.responses / 100) * 100)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-purple-500 to-pink-500 dark:from-purple-700 dark:to-pink-700 h-2 rounded-full transition-all duration-500"
|
|
||||||
style={{ width: `${Math.min((survey.responses / 100) * 100, 100)}%` }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Deadline */}
|
{/* Deadline */}
|
||||||
|
|
@ -205,7 +175,7 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{surveys?.length === 0 && (
|
{surveyList.length === 0 && (
|
||||||
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl">
|
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl">
|
||||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full mb-4">
|
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full mb-4">
|
||||||
<FaClipboardCheck className="w-8 h-8 text-gray-400 dark:text-gray-500" />
|
<FaClipboardCheck className="w-8 h-8 text-gray-400 dark:text-gray-500" />
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ const TodayBirthdays: React.FC<{ employees: UserInfoViewModel[] }> = ({ employee
|
||||||
<div className="p-2 space-y-3">
|
<div className="p-2 space-y-3">
|
||||||
{employees.length > 0 ? (
|
{employees.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||||
{employees.map((birthday, index) => (
|
{employees.map((birthday) => (
|
||||||
<div key={index} className="flex items-center gap-2 p-2">
|
<div key={birthday.id} className="flex items-center gap-2 p-2">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={48}
|
size={48}
|
||||||
shape="circle"
|
shape="circle"
|
||||||
|
|
|
||||||
1
ui/src/vite-env.d.ts
vendored
1
ui/src/vite-env.d.ts
vendored
|
|
@ -6,7 +6,6 @@ interface ImportMetaEnv {
|
||||||
readonly VITE_CDN_URL: string
|
readonly VITE_CDN_URL: string
|
||||||
readonly VITE_REACT_APP_VERSION: string
|
readonly VITE_REACT_APP_VERSION: string
|
||||||
readonly VITE_AI_URL: string
|
readonly VITE_AI_URL: string
|
||||||
readonly VITE_GOOGLE_MAPS_API_KEY: string
|
|
||||||
readonly VITE_USE_POLLING?: string
|
readonly VITE_USE_POLLING?: string
|
||||||
/** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */
|
/** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */
|
||||||
readonly VITE_PWA_DEV?: string
|
readonly VITE_PWA_DEV?: string
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue